|
@@ -0,0 +1,292 @@
|
|
|
|
+using Invercargill;
|
|
|
|
+using Invercargill.Convert;
|
|
|
|
+
|
|
|
|
+namespace Ppub {
|
|
|
|
+
|
|
|
|
+ public errordomain CollectionError {
|
|
|
|
+ INVALID_COLLECTION_SIGNATURE,
|
|
|
|
+ INVALID_MEMBER,
|
|
|
|
+ INVALID_MEMBER_SIGNATURE,
|
|
|
|
+ INVALID_ID,
|
|
|
|
+ INVALID_FORMAT
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public class Collection {
|
|
|
|
+ public uint8[] id { get; private set; }
|
|
|
|
+ public int serial { get; private set; }
|
|
|
|
+ public Vector<string> domains { get; private set; }
|
|
|
|
+ public Vector<CollectionMember> members { get; private set; }
|
|
|
|
+ public uint8[] auth { get; private set; }
|
|
|
|
+ public Vector<CollectionPublication> publications { get; private set; }
|
|
|
|
+ public DateTime modified { get; private set; }
|
|
|
|
+
|
|
|
|
+ private void initialise() {
|
|
|
|
+ domains = new Vector<string>();
|
|
|
|
+ members = new Vector<CollectionMember>();
|
|
|
|
+ publications = new Vector<CollectionPublication>();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public void increment_serial() {
|
|
|
|
+ serial++;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public void touch() {
|
|
|
|
+ modified = new DateTime.now_local();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public Collection(out uint8[] private_signing_key) {
|
|
|
|
+ id = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
|
|
|
|
+ private_signing_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
|
|
|
|
+ Sodium.Asymmetric.Signing.generate_keypair(id, private_signing_key);
|
|
|
|
+
|
|
|
|
+ initialise();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public Collection.from_stream(DataInputStream stream) throws Error {
|
|
|
|
+ var line = stream.read_line();
|
|
|
|
+ var signed_portion = line;
|
|
|
|
+ var header = line.split(" ");
|
|
|
|
+
|
|
|
|
+ if(header.length < 3) {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT("PPCL header contains less than three items");
|
|
|
|
+ }
|
|
|
|
+ if(header[0] != "PPCL") {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT("PPCL magic number missing from start of stream");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ id = Base64.decode(header[1]);
|
|
|
|
+ if(id.length != Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES) {
|
|
|
|
+ throw new CollectionError.INVALID_ID("Collection ID is invalid");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ int serial_int = 0;
|
|
|
|
+ if(!int.try_parse(header[2], out serial_int)) {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT("Could not parse serial number");
|
|
|
|
+ }
|
|
|
|
+ serial = serial_int;
|
|
|
|
+
|
|
|
|
+ initialise();
|
|
|
|
+
|
|
|
|
+ while(true) {
|
|
|
|
+ line = stream.read_line();
|
|
|
|
+ var entry = line.split(" ", 2);
|
|
|
|
+ if(entry.length != 2) {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT(@"Malformed collection entry \"$(entry[0])\"");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if(entry[0] == "MEM") {
|
|
|
|
+ members.add(new CollectionMember.from_string(entry[1]));
|
|
|
|
+ }
|
|
|
|
+ else if(entry[0] == "DOM") {
|
|
|
|
+ domains.add(entry[1]);
|
|
|
|
+ }
|
|
|
|
+ else if(entry[0] == "SIG") {
|
|
|
|
+ auth = Base64.decode(entry[1]);
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+ else {
|
|
|
|
+ warning(@"Unrecognised entry type $(entry[0]), ignoring");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ signed_portion += @"\n$line";
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ line = stream.read_line();
|
|
|
|
+ var modified_entry = line.split(" ", 2);
|
|
|
|
+ if(modified_entry.length != 2 || modified_entry[0] != "MOD") {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT(@"Malformed or missing last modified date");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ modified = new DateTime.from_iso8601(modified_entry[1], null);
|
|
|
|
+
|
|
|
|
+ line = stream.read_line();
|
|
|
|
+ if(line != "" && line != null) {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT(@"Line following MOD entry not blank");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // Verify auth
|
|
|
|
+ var checksum = new BinaryData.from_byte_array(get_string_checksum(signed_portion));
|
|
|
|
+ var signed_checksum = Sodium.Asymmetric.Signing.verify(auth, id);
|
|
|
|
+ if(signed_checksum == null) {
|
|
|
|
+ throw new CollectionError.INVALID_COLLECTION_SIGNATURE("Could not verify signature portion of collection");
|
|
|
|
+ }
|
|
|
|
+ if(!checksum.equals(ate(signed_checksum))) {
|
|
|
|
+ throw new CollectionError.INVALID_COLLECTION_SIGNATURE("Signature checksum does not match calculated checksum");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if(line == null) {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ while(true) {
|
|
|
|
+ line = stream.read_line();
|
|
|
|
+ if(line == "" || line == null) {
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+ var entry = line.split(" ", 2);
|
|
|
|
+
|
|
|
|
+ if(entry[0] == "PUB") {
|
|
|
|
+ publications.add(new CollectionPublication.from_string(entry[1], members));
|
|
|
|
+ }
|
|
|
|
+ else {
|
|
|
|
+ warning(@"Unrecognised entry type $(entry[0]), ignoring");
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public string to_string(uint8[] signing_key) {
|
|
|
|
+ var strings = domains
|
|
|
|
+ .select<string>(d => @"DOM $d")
|
|
|
|
+ .concat(members.select<string>(m => m.to_string()));
|
|
|
|
+ var data = strings.to_string(s => s, "\n");
|
|
|
|
+ var pub_str = publications.to_string(p => p.to_string(), "\n");
|
|
|
|
+
|
|
|
|
+ var signed_portion = @"PPCL $(Base64.encode(id)) $serial";
|
|
|
|
+ if(data != "") {
|
|
|
|
+ signed_portion += @"\n$data";
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ auth = Sodium.Asymmetric.Signing.sign(get_string_checksum(signed_portion), signing_key);
|
|
|
|
+
|
|
|
|
+ var full_str = @"$signed_portion\nSIG $(Base64.encode(auth))\nMOD $(modified.format_iso8601())\n";
|
|
|
|
+ if(pub_str != "") {
|
|
|
|
+ full_str += @"\n$pub_str\n";
|
|
|
|
+ }
|
|
|
|
+ return full_str;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ internal static uint8[] get_string_checksum(string data) {
|
|
|
|
+ var signed_data = new BinaryData();
|
|
|
|
+ signed_data.append_string(data);
|
|
|
|
+ var checksum_calculator = new Checksum(ChecksumType.SHA512);
|
|
|
|
+ var arr = signed_data.to_array();
|
|
|
|
+ print(@"ARR.length $(arr.length), str $data\n");
|
|
|
|
+ checksum_calculator.update(arr, arr.length);
|
|
|
|
+ var checksum = new uint8[64];
|
|
|
|
+ size_t chk_len = checksum.length;
|
|
|
|
+ checksum_calculator.get_digest(checksum, ref chk_len);
|
|
|
|
+ checksum.length = (int)chk_len;
|
|
|
|
+ return checksum;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public class CollectionMember {
|
|
|
|
+ public string name { get; private set; }
|
|
|
|
+ public uint8[] public_key { get; private set; }
|
|
|
|
+
|
|
|
|
+ public string to_string() {
|
|
|
|
+ return @"MEM $name $(Base64.encode(public_key))";
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public CollectionMember(string name, uint8[] public_key) {
|
|
|
|
+ this.name = name;
|
|
|
|
+ this.public_key = public_key;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public CollectionMember.from_string(string line) throws CollectionError {
|
|
|
|
+ var parts = line.split(" ");
|
|
|
|
+ if(parts.length < 2) {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT("Member line must contain at least two fields, name and public key");
|
|
|
|
+ }
|
|
|
|
+ name = parts[0];
|
|
|
|
+ public_key = Base64.decode(parts[1]);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public static void new_keypair(out uint8[] private_key, out uint8[] public_key) {
|
|
|
|
+ private_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
|
|
|
|
+ public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
|
|
|
|
+ Sodium.Asymmetric.Signing.generate_keypair(public_key, private_key);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public class CollectionPublication {
|
|
|
|
+ public string file_name { get; private set; }
|
|
|
|
+ public DateTime publication_time { get; private set; }
|
|
|
|
+ public string member { get; private set; }
|
|
|
|
+ public uint8[] signature { get; private set; }
|
|
|
|
+ public uint8[] publication_checksum { get; private set; }
|
|
|
|
+
|
|
|
|
+ private string raw_line;
|
|
|
|
+
|
|
|
|
+ public string to_string() {
|
|
|
|
+ return raw_line;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private string get_signed_portion() {
|
|
|
|
+ return @"$file_name: $member $(publication_time.format_iso8601())";
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public static uint8[] generate_ppub_checksum(File ppub_file) throws Error {
|
|
|
|
+ var buffer = new uint8[10240];
|
|
|
|
+ var checksum = new Checksum(ChecksumType.SHA512);
|
|
|
|
+ var stream = ppub_file.read();
|
|
|
|
+ while(true) {
|
|
|
|
+ var read = stream.read(buffer);
|
|
|
|
+ if(read == 0) {
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+ checksum.update(buffer, read);
|
|
|
|
+ }
|
|
|
|
+ size_t dig_len = 64;
|
|
|
|
+ var digest = new uint8[64];
|
|
|
|
+ checksum.get_digest(digest, ref dig_len);
|
|
|
|
+ digest.length = (int)dig_len;
|
|
|
|
+ return digest;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public CollectionPublication(string file_name, DateTime publication_time, string member, uint8[] ppub_checksum, uint8[] signing_key) {
|
|
|
|
+ this.file_name = file_name;
|
|
|
|
+ this.publication_time = publication_time;
|
|
|
|
+ this.member = member;
|
|
|
|
+ this.publication_checksum = ppub_checksum;
|
|
|
|
+ sign(signing_key);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private void sign(uint8[] signing_key) {
|
|
|
|
+ var signed_portion = get_signed_portion();
|
|
|
|
+ var line_checksum = Collection.get_string_checksum(signed_portion);
|
|
|
|
+ var to_sign = new BinaryData.from_byte_array(line_checksum);
|
|
|
|
+ to_sign.append_byte_array(publication_checksum);
|
|
|
|
+
|
|
|
|
+ signature = Sodium.Asymmetric.Signing.sign(to_sign.to_array(), signing_key);
|
|
|
|
+
|
|
|
|
+ raw_line = @"PUB $signed_portion $(Base64.encode(signature))";
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public CollectionPublication.from_string(string data, Enumerable<CollectionMember> members) throws CollectionError{
|
|
|
|
+ raw_line = "PUB " + data;
|
|
|
|
+ var entry = data.split(": ", 2);
|
|
|
|
+ if(entry.length < 2) {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT("Malformed publication entry");
|
|
|
|
+ }
|
|
|
|
+ var parts = entry[1].split(" ");
|
|
|
|
+ if(parts.length < 3) {
|
|
|
|
+ throw new CollectionError.INVALID_FORMAT("Publication line must contain at least 3 properties, member, time, and signature");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ file_name = entry[0];
|
|
|
|
+ member = parts[0];
|
|
|
|
+ publication_time = new DateTime.from_iso8601(parts[1], null);
|
|
|
|
+ signature = Base64.decode(parts[2]);
|
|
|
|
+
|
|
|
|
+ var collection_member = members.first_or_default(m => m.name == member);
|
|
|
|
+ if(collection_member == null) {
|
|
|
|
+ throw new CollectionError.INVALID_MEMBER(@"Undeclared member \"$member\" on publication entry");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ var checksum = new BinaryData.from_byte_array(Collection.get_string_checksum(get_signed_portion()));
|
|
|
|
+ print(@"\t\t$(get_signed_portion()) == $(Base64.encode(checksum.to_array()))\n");
|
|
|
|
+ var signature_data = Sodium.Asymmetric.Signing.verify(signature, collection_member.public_key);
|
|
|
|
+ if(signature_data == null) {
|
|
|
|
+ throw new CollectionError.INVALID_COLLECTION_SIGNATURE("Invalid publication signature");
|
|
|
|
+ }
|
|
|
|
+ if(!checksum.equals(ate(signature_data[0:64]))) {
|
|
|
|
+ throw new CollectionError.INVALID_COLLECTION_SIGNATURE("Publication signature does not match metadata");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ publication_checksum = signature_data[64:128].copy();
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+}
|