From 4693117c76f3a8f2ace49dd8ba987169e7145e62 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Fri, 17 Nov 2023 08:05:00 -0500 Subject: [PATCH] feat: add metadata structures and ser/der functions --- src/serialization/metadata/base.ts | 3 + src/serialization/metadata/directory.ts | 437 ++++++++++++++++ src/serialization/metadata/extra.ts | 60 +++ src/serialization/metadata/media.ts | 525 ++++++++++++++++++++ src/serialization/metadata/parent.ts | 34 ++ src/serialization/metadata/user_identity.ts | 39 ++ src/serialization/metadata/webapp.ts | 114 +++++ 7 files changed, 1212 insertions(+) create mode 100644 src/serialization/metadata/base.ts create mode 100644 src/serialization/metadata/directory.ts create mode 100644 src/serialization/metadata/extra.ts create mode 100644 src/serialization/metadata/media.ts create mode 100644 src/serialization/metadata/parent.ts create mode 100644 src/serialization/metadata/user_identity.ts create mode 100644 src/serialization/metadata/webapp.ts diff --git a/src/serialization/metadata/base.ts b/src/serialization/metadata/base.ts new file mode 100644 index 0000000..d5a31dc --- /dev/null +++ b/src/serialization/metadata/base.ts @@ -0,0 +1,3 @@ +export default abstract class Metadata { + abstract toJson(): { [key: string]: any }; +} diff --git a/src/serialization/metadata/directory.ts b/src/serialization/metadata/directory.ts new file mode 100644 index 0000000..7600783 --- /dev/null +++ b/src/serialization/metadata/directory.ts @@ -0,0 +1,437 @@ +import Metadata from "#serialization/metadata/base.js"; +import Packer from "#serialization/pack.js"; +import { metadataMagicByte } from "lib/index.js"; +import { METADATA_TYPES } from "#constants.js"; +import Unpacker from "#serialization/unpack.js"; +import ExtraMetadata from "#serialization/metadata/extra.js"; +import { Buffer } from "buffer"; +import CID from "#cid.js"; +import { Multihash } from "#multihash.js"; +import { base64url } from "multiformats/bases/base64"; +import EncryptedCID from "#encrypted_cid.js"; + +export default class DirectoryMetadata extends Metadata { + details: DirectoryMetadataDetails; + directories: { [key: string]: DirectoryReference }; + files: { [key: string]: FileReference }; + extraMetadata: ExtraMetadata; + + constructor( + details: DirectoryMetadataDetails, + directories: { [key: string]: DirectoryReference }, + files: { [key: string]: FileReference }, + extraMetadata: ExtraMetadata, + ) { + super(); + this.details = details; + this.directories = directories; + this.files = files; + this.extraMetadata = extraMetadata; + } + + serialize(): Uint8Array { + const p = new Packer(); + p.packInt(metadataMagicByte); + p.packInt(METADATA_TYPES.DIRECTORY); + + p.packListLength(4); + + p.pack(this.details.data); + + p.packMapLength(Object.keys(this.directories).length); + Object.entries(this.directories).forEach(([key, value]) => { + p.packString(key); + p.pack(value.encode()); + }); + + p.packMapLength(Object.keys(this.files).length); + Object.entries(this.files).forEach(([key, value]) => { + p.packString(key); + p.pack(value.encode()); + }); + + p.pack(this.extraMetadata.data); + + return p.takeBytes(); + } + + toJson(): { [key: string]: any } { + return { + type: "directory", + details: this.details, + directories: this.directories, + files: this.files, + extraMetadata: this.extraMetadata, + }; + } + + static deserialize(bytes: Uint8Array): DirectoryMetadata { + const u = new Unpacker(Buffer.from(bytes)); + + const magicByte = u.unpackInt(); + if (magicByte !== metadataMagicByte) { + throw new Error("Invalid metadata: Unsupported magic byte"); + } + const typeAndVersion = u.unpackInt(); + if (typeAndVersion !== METADATA_TYPES.DIRECTORY) { + throw new Error("Invalid metadata: Wrong metadata type"); + } + + u.unpackListLength(); + + const dir = new DirectoryMetadata( + new DirectoryMetadataDetails(u.unpackMap()), + {}, + {}, + new ExtraMetadata({}), + ); + + const dirCount = u.unpackMapLength(); + for (let i = 0; i < dirCount; i++) { + const key = u.unpackString(); + dir.directories[key as string] = DirectoryReference.decode(u.unpackMap()); + } + + const fileCount = u.unpackMapLength(); + for (let i = 0; i < fileCount; i++) { + const key = u.unpackString(); + dir.files[key as string] = FileReference.decode(u.unpackMap()); + } + + Object.assign(dir.extraMetadata.data, u.unpackMap()); + return dir; + } +} + +class DirectoryMetadataDetails { + data: Map; + + constructor(data: Map | object) { + if (data instanceof Map && typeof data == "object") { + data = Object.entries(data).map(([key, value]) => [Number(key), value]); + } + this.data = data as Map; + } + + get isShared(): boolean { + return this.data.has(3); + } + + get isSharedReadOnly(): boolean { + return this.data.get(3)?.[1] ?? false; + } + + get isSharedReadWrite(): boolean { + return this.data.get(3)?.[2] ?? false; + } + + setShared(value: boolean, write: boolean): void { + if (!this.data.has(3)) { + this.data.set(3, {}); + } + this.data.get(3)[write ? 2 : 1] = value; + } + + toJson(): { [key: string]: any } { + // Convert the data Map to a JSON object + const jsonObject: { [key: string]: any } = {}; + this.data.forEach((value, key) => { + jsonObject[key.toString()] = value; + }); + return jsonObject; + } +} + +class DirectoryReference { + created: number; + name: string; + encryptedWriteKey: Uint8Array; + publicKey: Uint8Array; + encryptionKey: Uint8Array | null; + ext: { [key: string]: any } | null; + + uri: string | null; // For internal operations + key: string | null; // For internal operations + size: number | null; // For internal operations + + constructor( + created: number, + name: string, + encryptedWriteKey: Uint8Array, + publicKey: Uint8Array, + encryptionKey: Uint8Array | null, + ext: { [key: string]: any } | null, + ) { + this.created = created; + this.name = name; + this.encryptedWriteKey = encryptedWriteKey; + this.publicKey = publicKey; + this.encryptionKey = encryptionKey; + this.ext = ext; + this.uri = null; + this.key = null; + this.size = null; + } + + toJson(): { [key: string]: any } { + return { + name: this.name, + created: this.created, + publicKey: base64url.encode(this.publicKey), + encryptedWriteKey: base64url.encode(this.encryptedWriteKey), + encryptionKey: this.encryptionKey + ? base64url.encode(this.encryptionKey) + : null, + ext: this.ext, + }; + } + + static decode(data: { [key: number]: any }): DirectoryReference { + return new DirectoryReference( + data[2], + data[1], + data[4], + data[3], + data[5], + data[6] ? (data[6] as { [key: string]: any }) : null, + ); + } + + encode(): { [key: number]: any } { + const map: { [key: number]: any } = { + 1: this.name, + 2: this.created, + 3: this.publicKey, + 4: this.encryptedWriteKey, + }; + + if (this.encryptionKey !== null) { + map[5] = this.encryptionKey; + } + if (this.ext !== null) { + map[6] = this.ext; + } + + return map; + } +} +class FileReference { + created: number; + file: FileVersion; + history: Map | null; + mimeType: string | null; + name: string; + version: number; + ext: { [key: string]: any } | null; + + uri: string | null; // For internal operations + key: string | null; // For internal operations + + constructor( + name: string, + created: number, + version: number, + file: FileVersion, + ext: { [key: string]: any } | null = null, + history: Map | null = null, + mimeType: string | null = null, + ) { + this.name = name; + this.created = created; + this.version = version; + this.file = file; + this.ext = ext; + this.history = history; + this.mimeType = mimeType; + this.uri = null; + this.key = null; + } + + get modified(): number { + return this.file.ts; + } + + toJson(): { [key: string]: any } { + return { + name: this.name, + created: this.created, + modified: this.modified, + version: this.version, + mimeType: this.mimeType, + file: this.file.toJson(), + ext: this.ext, + history: this.history + ? Array.from(this.history.values()).map((fv) => fv.toJson()) + : null, + }; + } + + static decode(data: { [key: number]: any }): FileReference { + const historyData = data[8] as { [key: number]: any } | undefined; + const history = historyData + ? new Map( + Object.entries(historyData).map(([k, v]) => [ + Number(k), + FileVersion.decode(v), + ]), + ) + : null; + + return new FileReference( + data[1], + data[2], + data[5], + FileVersion.decode(data[4]), + data[7] ? (data[7] as { [key: string]: any }) : null, + history, + data[6], + ); + } + + encode(): { [key: number]: any } { + const data: { [key: number]: any } = { + 1: this.name, + 2: this.created, + 4: this.file.encode(), + 5: this.version, + }; + + if (this.mimeType !== null) { + data[6] = this.mimeType; + } + if (this.ext !== null) { + data[7] = this.ext; + } + if (this.history !== null) { + data[8] = Array.from(this.history.entries()).reduce( + (obj, [key, value]) => { + obj[key] = value.encode(); + return obj; + }, + {} as { [key: number]: any }, + ); + } + + return data; + } +} + +class FileVersion { + ts: number; + encryptedCID?: EncryptedCID; + plaintextCID?: CID; + thumbnail?: FileVersionThumbnail; + hashes?: Multihash[]; + ext?: { [key: string]: any }; + + constructor( + ts: number, + encryptedCID?: EncryptedCID, + plaintextCID?: CID, + thumbnail?: FileVersionThumbnail, + hashes?: Multihash[], + ext?: { [key: string]: any }, + ) { + this.ts = ts; + this.encryptedCID = encryptedCID; + this.plaintextCID = plaintextCID; + this.thumbnail = thumbnail; + this.hashes = hashes; + this.ext = ext; + } + + get cid(): CID { + return this.plaintextCID ?? this.encryptedCID!.originalCID; + } + + static decode(data: { [key: number]: any }): FileVersion { + return new FileVersion( + data[8], + data[1] == null ? undefined : EncryptedCID.fromBytes(data[1]), + data[2] == null ? undefined : CID.fromBytes(data[2]), + data[10] == null ? undefined : FileVersionThumbnail.decode(data[10]), + data[9] ? data[9].map((e: any) => new Multihash(e)) : null, + ); + } + + encode(): { [key: number]: any } { + const data: { [key: number]: any } = { 8: this.ts }; + + if (!!this.encryptedCID) { + data[1] = this.encryptedCID.toBytes(); + } + if (!!this.plaintextCID) { + data[2] = this.plaintextCID.toBytes(); + } + if (!!this.hashes) { + data[9] = this.hashes.map((e) => e.fullBytes); + } + if (!!this.thumbnail) { + data[10] = this.thumbnail.encode(); + } + + return data; + } + + toJson(): { [key: string]: any } { + return { + ts: this.ts, + encryptedCID: this.encryptedCID?.toBase58(), + cid: this.cid.toBase58(), + hashes: this.hashes?.map((e) => e.toBase64Url()), + thumbnail: this.thumbnail?.toJson(), + }; + } +} +class FileVersionThumbnail { + imageType: string | null; + aspectRatio: number; + cid: EncryptedCID; + thumbhash: Uint8Array | null; + + constructor( + imageType: string | null, + aspectRatio: number, + cid: EncryptedCID, + thumbhash: Uint8Array | null, + ) { + this.imageType = imageType || "webp"; // Default to 'webp' if not provided + this.aspectRatio = aspectRatio; + this.cid = cid; + this.thumbhash = thumbhash; + } + + toJson(): { [key: string]: any } { + return { + imageType: this.imageType, + aspectRatio: this.aspectRatio, + cid: this.cid.toBase58(), + thumbhash: this.thumbhash ? base64url.encode(this.thumbhash) : null, + }; + } + + static decode(data: { [key: number]: any }): FileVersionThumbnail { + return new FileVersionThumbnail( + data[1], + data[2], + EncryptedCID.fromBytes(data[3]), + data[4], + ); + } + + encode(): { [key: number]: any } { + const data: { [key: number]: any } = { + 2: this.aspectRatio, + 3: this.cid.toBytes(), + }; + + if (this.imageType !== null) { + data[1] = this.imageType; + } + if (this.thumbhash !== null) { + data[4] = this.thumbhash; + } + + return data; + } +} diff --git a/src/serialization/metadata/extra.ts b/src/serialization/metadata/extra.ts new file mode 100644 index 0000000..f0f4fc4 --- /dev/null +++ b/src/serialization/metadata/extra.ts @@ -0,0 +1,60 @@ +import { + metadataExtensionBasicMediaMetadata, + metadataExtensionBridge, + metadataExtensionCategories, + metadataExtensionDonationKeys, + metadataExtensionLanguages, + metadataExtensionLicenses, + metadataExtensionOriginalTimestamp, + metadataExtensionPreviousVersions, + metadataExtensionRoutingHints, + metadataExtensionSourceUris, + metadataExtensionTags, + metadataExtensionTimestamp, + metadataExtensionUpdateCID, + metadataExtensionViewTypes, + metadataExtensionWikidataClaims, +} from "#constants.js"; +import CID from "#cid.js"; + +export default class ExtraMetadata { + data: Map; + + constructor(data: Map | object) { + if (data instanceof Map && typeof data == "object") { + data = Object.entries(data).map(([key, value]) => [Number(key), value]); + } + this.data = data as Map; + } + + toJson(): { [key: string]: any } { + const map: { [key: string]: any } = {}; + const names: { [key: number]: string } = { + [metadataExtensionLicenses]: "licenses", + [metadataExtensionDonationKeys]: "donationKeys", + [metadataExtensionWikidataClaims]: "wikidataClaims", + [metadataExtensionLanguages]: "languages", + [metadataExtensionSourceUris]: "sourceUris", + // metadataExtensionUpdateCID: 'updateCID', + [metadataExtensionPreviousVersions]: "previousVersions", + [metadataExtensionTimestamp]: "timestamp", + [metadataExtensionOriginalTimestamp]: "originalTimestamp", + [metadataExtensionTags]: "tags", + [metadataExtensionCategories]: "categories", + [metadataExtensionBasicMediaMetadata]: "basicMediaMetadata", + [metadataExtensionViewTypes]: "viewTypes", + [metadataExtensionBridge]: "bridge", + [metadataExtensionRoutingHints]: "routingHints", + }; + + this.data.forEach((value, key) => { + if (key === metadataExtensionUpdateCID) { + map["updateCID"] = CID.fromBytes(value).toString(); + } else { + map[names[key]] = value; + } + }); + + return map; + } +} diff --git a/src/serialization/metadata/media.ts b/src/serialization/metadata/media.ts new file mode 100644 index 0000000..e6d4c95 --- /dev/null +++ b/src/serialization/metadata/media.ts @@ -0,0 +1,525 @@ +import Metadata from "#serialization/metadata/base.js"; +import CID from "#cid.js"; +import { + CID_HASH_TYPES, + METADATA_TYPES, + metadataMagicByte, + metadataMediaDetailsDuration, + metadataMediaDetailsIsLive, + metadataProofTypeSignature, + parentLinkTypeUserIdentity, +} from "#constants.js"; +import ExtraMetadata from "#serialization/metadata/extra.js"; +import { MetadataParentLink } from "#serialization/metadata/parent.js"; +import { Multihash } from "#multihash.js"; +import { decodeEndian, encodeEndian } from "#util.js"; +import Unpacker from "#serialization/unpack.js"; +import { Buffer } from "buffer"; +import { blake3 } from "@noble/hashes/blake3"; +import { ed25519 } from "@noble/curves/ed25519"; +import KeyPairEd25519 from "#ed25519.js"; +import Packer from "#serialization/pack.js"; + +export default class MediaMetadata extends Metadata { + name: string; + mediaTypes: { [key: string]: MediaFormat[] }; + parents: MetadataParentLink[]; + details: MediaMetadataDetails; + links: MediaMetadataLinks | null; + extraMetadata: ExtraMetadata; + + constructor( + name: string, + details: MediaMetadataDetails, + parents: MetadataParentLink[], + mediaTypes: { [key: string]: MediaFormat[] }, + links: MediaMetadataLinks | null, + extraMetadata: ExtraMetadata, + ) { + super(); + this.name = name; + this.details = details; + this.parents = parents; + this.mediaTypes = mediaTypes; + this.links = links; + this.extraMetadata = extraMetadata; + } + + toJson(): { [key: string]: any } { + return { + type: "media", + name: this.name, + details: this.details, + parents: this.parents, + mediaTypes: this.mediaTypes, + links: this.links, + extraMetadata: this.extraMetadata, + }; + } +} + +class MediaMetadataLinks { + count: number; + head: CID[]; + collapsed: CID[] | null; + tail: CID[] | null; + + constructor(head: CID[]) { + this.head = head; + this.count = head.length; + this.collapsed = null; + this.tail = null; + } + + toJson(): { [key: string]: any } { + const map: { [key: string]: any } = { + count: this.count, + head: this.head.map((e) => e.toString()), + }; + if (this.collapsed != null) { + map["collapsed"] = this.collapsed.map((e) => e.toString()); + } + if (this.tail != null) { + map["tail"] = this.tail.map((e) => e.toString()); + } + return map; + } + + static decode(links: { [key: number]: any }): MediaMetadataLinks { + const count = links[1] as number; + const head = (links[2] as Uint8Array[]).map((bytes) => + CID.fromBytes(bytes), + ); + const collapsed = links[3] + ? (links[3] as Uint8Array[]).map((bytes) => CID.fromBytes(bytes)) + : null; + const tail = links[4] + ? (links[4] as Uint8Array[]).map((bytes) => CID.fromBytes(bytes)) + : null; + + const instance = new MediaMetadataLinks(head); + instance.count = count; + instance.collapsed = collapsed; + instance.tail = tail; + + return instance; + } + + encode(): { [key: number]: any } { + const data: { [key: number]: any } = { + 1: this.count, + 2: this.head, + }; + + const addNotNull = (key: number, value: any) => { + if (value !== null && value !== undefined) { + data[key] = value; + } + }; + + addNotNull(3, this.collapsed); + addNotNull(4, this.tail); + + return data; + } +} + +class MediaMetadataDetails { + data: { [key: number]: any }; + + constructor(data: { [key: number]: any }) { + this.data = data; + } + + toJson(): { [key: string]: any } { + const map: { [key: string]: any } = {}; + const names: { [key: number]: string } = { + [metadataMediaDetailsDuration]: "duration", + [metadataMediaDetailsIsLive]: "live", + }; + Object.entries(this.data).forEach(([key, value]) => { + map[names[+key]] = value; + }); + + return map; + } + + get duration(): number | null { + return this.data[metadataMediaDetailsDuration]; + } + + get isLive(): boolean { + return !!this.data[metadataMediaDetailsIsLive]; + } +} + +class MediaFormat { + subtype: string; + role: string | null; + ext: string | null; + cid: CID | null; + height: number | null; + width: number | null; + languages: string[] | null; + asr: number | null; + fps: number | null; + bitrate: number | null; + audioChannels: number | null; + vcodec: string | null; + acodec: string | null; + container: string | null; + dynamicRange: string | null; + charset: string | null; + value: Uint8Array | null; + duration: number | null; + rows: number | null; + columns: number | null; + index: number | null; + initRange: string | null; + indexRange: string | null; + caption: string | null; + + constructor( + subtype: string, + role: string | null, + ext: string | null, + cid: CID | null, + height: number | null, + width: number | null, + languages: string[] | null, + asr: number | null, + fps: number | null, + bitrate: number | null, + audioChannels: number | null, + vcodec: string | null, + acodec: string | null, + container: string | null, + dynamicRange: string | null, + charset: string | null, + value: Uint8Array | null, + duration: number | null, + rows: number | null, + columns: number | null, + index: number | null, + initRange: string | null, + indexRange: string | null, + caption: string | null, + ) { + this.subtype = subtype; + this.role = role; + this.ext = ext; + this.cid = cid; + this.height = height; + this.width = width; + this.languages = languages; + this.asr = asr; + this.fps = fps; + this.bitrate = bitrate; + this.audioChannels = audioChannels; + this.vcodec = vcodec; + this.acodec = acodec; + this.container = container; + this.dynamicRange = dynamicRange; + this.charset = charset; + this.value = value; + this.duration = duration; + this.rows = rows; + this.columns = columns; + this.index = index; + this.initRange = initRange; + this.indexRange = indexRange; + this.caption = caption; + } + + get valueAsString(): string | null { + if (this.value === null) { + return null; + } + return new TextDecoder().decode(this.value); + } + + static decode(data: { [key: number]: any }): MediaFormat { + return new MediaFormat( + data[2], // subtype + data[3], // role + data[4], // ext + data[1] == null ? null : CID.fromBytes(Uint8Array.from(data[1])), + data[10], // height + data[11], // width + data[12] ? (data[12] as string[]) : null, // languages + data[13], // asr + data[14], // fps + data[15], // bitrate + data[18], // audioChannels + data[19], // vcodec + data[20], // acodec + data[21], // container + data[22], // dynamicRange + data[23], // charset + data[24] == null ? null : Uint8Array.from(data[24]), // value + data[25], // duration + data[26], // rows + data[27], // columns + data[28], // index + data[29], // initRange + data[30], // indexRange + data[31], // caption + ); + } + + encode(): { [key: number]: any } { + const data: { [key: number]: any } = {}; + + const addNotNull = (key: number, value: any) => { + if (value !== null && value !== undefined) { + data[key] = value; + } + }; + + addNotNull(1, this.cid?.toBytes()); + addNotNull(2, this.subtype); + addNotNull(3, this.role); + addNotNull(4, this.ext); + addNotNull(10, this.height); + addNotNull(11, this.width); + addNotNull(12, this.languages); + addNotNull(13, this.asr); + addNotNull(14, this.fps); + addNotNull(15, this.bitrate); + // addNotNull(16, this.abr); + // addNotNull(17, this.vbr); + addNotNull(18, this.audioChannels); + addNotNull(19, this.vcodec); + addNotNull(20, this.acodec); + addNotNull(21, this.container); + addNotNull(22, this.dynamicRange); + addNotNull(23, this.charset); + addNotNull(24, this.value); + addNotNull(25, this.duration); + addNotNull(26, this.rows); + addNotNull(27, this.columns); + addNotNull(28, this.index); + addNotNull(29, this.initRange); + addNotNull(30, this.indexRange); + addNotNull(31, this.caption); + + return data; + } + toJson(): { [key: string]: any } { + const data: { [key: string]: any } = {}; + + const addNotNull = (key: string, value: any) => { + if (value !== null && value !== undefined) { + data[key] = value; + } + }; + + addNotNull("cid", this.cid?.toBase64Url()); + addNotNull("subtype", this.subtype); + addNotNull("role", this.role); + addNotNull("ext", this.ext); + addNotNull("height", this.height); + addNotNull("width", this.width); + addNotNull("languages", this.languages); + addNotNull("asr", this.asr); + addNotNull("fps", this.fps); + addNotNull("bitrate", this.bitrate); + // addNotNull('abr', this.abr); + // addNotNull('vbr', this.vbr); + addNotNull("audioChannels", this.audioChannels); + addNotNull("vcodec", this.vcodec); + addNotNull("acodec", this.acodec); + addNotNull("container", this.container); + addNotNull("dynamicRange", this.dynamicRange); + addNotNull("charset", this.charset); + addNotNull("value", this.value ? this.valueAsString : null); // Assuming valueAsString() is a method to convert value to string + addNotNull("duration", this.duration); + addNotNull("rows", this.rows); + addNotNull("columns", this.columns); + addNotNull("index", this.index); + addNotNull("initRange", this.initRange); + addNotNull("indexRange", this.indexRange); + addNotNull("caption", this.caption); + + return data; + } +} + +export async function deserialize(bytes: Uint8Array): Promise { + const magicByte = bytes[0]; + if (magicByte !== metadataMagicByte) { + throw new Error("Invalid metadata: Unsupported magic byte"); + } + const typeAndVersion = bytes[1]; + let bodyBytes: Uint8Array; + + const provenPubKeys: Multihash[] = []; + + if (typeAndVersion === METADATA_TYPES.PROOF) { + const proofSectionLength = decodeEndian(bytes.subarray(2, 4)); + + bodyBytes = bytes.subarray(4 + proofSectionLength); + + if (proofSectionLength > 0) { + const proofUnpacker = new Unpacker( + Buffer.from(bytes.subarray(4, 4 + proofSectionLength)), + ); + + const b3hash = Uint8Array.from([ + CID_HASH_TYPES.BLAKE3, + ...blake3(bodyBytes), + ]); + + const proofCount = proofUnpacker.unpackListLength(); + + for (let i = 0; i < proofCount; i++) { + const parts = proofUnpacker.unpackList(); + const proofType = parts[0] as number; + + if (proofType === metadataProofTypeSignature) { + const mhashType = parts[1] as number; + const pubkey = parts[2] as Uint8Array; + const signature = parts[3] as Uint8Array; + + if (mhashType !== CID_HASH_TYPES.BLAKE3) { + throw new Error(`Hash type ${mhashType} not supported`); + } + + if (pubkey[0] !== CID_HASH_TYPES.ED25519) { + throw new Error("Only ed25519 keys are supported"); + } + if (pubkey.length !== 33) { + throw new Error("Invalid userId"); + } + + const isValid = await ed25519.verify( + signature, + b3hash, + pubkey.subarray(1), + ); + + if (!isValid) { + throw new Error("Invalid signature found"); + } + provenPubKeys.push(new Multihash(pubkey)); + } else { + // Unsupported proof type + } + } + } + } else if (typeAndVersion === METADATA_TYPES.MEDIA) { + bodyBytes = bytes.subarray(1); + } else { + throw new Error(`Invalid metadata: Unsupported type ${typeAndVersion}`); + } + + // Start of body section + const u = new Unpacker(Buffer.from(bodyBytes)); + const type = u.unpackInt(); + + if (type !== METADATA_TYPES.MEDIA) { + throw new Error(`Invalid metadata: Unsupported type ${type}`); + } + + u.unpackListLength(); + const name = u.unpackString(); + const details = new MediaMetadataDetails(u.unpackMap()); + + const parents: MetadataParentLink[] = []; + const userCount = u.unpackListLength(); + for (let i = 0; i < userCount; i++) { + const m = u.unpackMap(); + const cid = CID.fromBytes(m[1] as Uint8Array); + + parents.push( + new MetadataParentLink( + cid, + (m[0] ?? parentLinkTypeUserIdentity) as number, + m[2], + provenPubKeys.some((pk) => pk.equals(cid.hash)), // Assuming Multihash class has an equals method + ), + ); + } + + const mediaTypesMap = u.unpackMap() as Record; + const mediaTypes: Record = {}; + + Object.entries(mediaTypesMap).forEach(([type, formats]) => { + mediaTypes[type] = formats.map((e: any) => + MediaFormat.decode(e as Record), + ); + }); + + const links = u.unpackMap(); + const extraMetadata = u.unpackMap(); + + return new MediaMetadata( + name || "", + details, + parents, + mediaTypes, + links.size > 0 ? MediaMetadataLinks.decode(links) : null, + new ExtraMetadata(extraMetadata), + ); +} + +export async function serialize( + m: MediaMetadata, + keyPairs: KeyPairEd25519[] = [], +): Promise { + const c = new Packer(); + c.packInt(METADATA_TYPES.MEDIA); + c.packListLength(6); + + c.packString(m.name); + c.pack(m.details.data); + + c.packListLength(m.parents.length); + for (const parent of m.parents) { + c.pack({ 0: parent.type, 1: parent.cid.toBytes() }); + } + + c.packMapLength(Object.keys(m.mediaTypes).length); + for (const [key, value] of Object.entries(m.mediaTypes)) { + c.packString(key); + c.pack(value); + } + + if (m.links === null) { + c.packMapLength(0); + } else { + c.pack(m.links.encode()); + } + + c.pack(m.extraMetadata.data); + + const bodyBytes = c.takeBytes(); + + if (keyPairs.length === 0) { + return Uint8Array.from([metadataMagicByte, ...bodyBytes]); + } + + const b3hash = Uint8Array.from([CID_HASH_TYPES.BLAKE3, ...blake3(bodyBytes)]); + + const proofPacker = new Packer(); + proofPacker.packListLength(keyPairs.length); + + for (const kp of keyPairs) { + const signature = await ed25519.sign(b3hash, kp.extractBytes()); + proofPacker.pack([ + metadataProofTypeSignature, + CID_HASH_TYPES.BLAKE3, + kp.publicKey, + signature, + ]); + } + const proofBytes = proofPacker.takeBytes(); + + const header = [ + metadataMagicByte, + METADATA_TYPES.PROOF, + ...encodeEndian(proofBytes.length, 2), + ]; + + return Uint8Array.from([...header, ...proofBytes, ...bodyBytes]); +} diff --git a/src/serialization/metadata/parent.ts b/src/serialization/metadata/parent.ts new file mode 100644 index 0000000..2728059 --- /dev/null +++ b/src/serialization/metadata/parent.ts @@ -0,0 +1,34 @@ +import CID from "#cid.js"; +import { parentLinkTypeUserIdentity } from "#constants.js"; + +export class MetadataParentLink { + cid: CID; + type: number; + role: string | null; + signed: boolean; + + constructor( + cid: CID, + type: number = parentLinkTypeUserIdentity, + role: string | null = null, + signed: boolean = false, + ) { + this.cid = cid; + this.type = type; + this.role = role; + this.signed = signed; + } + + toJson(): { [key: string]: any } { + const map: { [key: string]: any } = {}; + + map["cid"] = this.cid.toString(); + map["type"] = this.type; + if (this.role !== null) { + map["role"] = this.role; + } + map["signed"] = this.signed; + + return map; + } +} diff --git a/src/serialization/metadata/user_identity.ts b/src/serialization/metadata/user_identity.ts new file mode 100644 index 0000000..c239933 --- /dev/null +++ b/src/serialization/metadata/user_identity.ts @@ -0,0 +1,39 @@ +import CID from "#cid.js"; + +export default class UserIdentityMetadata { + userID?: CID; + details: UserIdentityMetadataDetails; + signingKeys: UserIdentityPublicKey[]; + encryptionKeys: UserIdentityPublicKey[]; + links: Map; + + constructor( + details: UserIdentityMetadataDetails, + signingKeys: UserIdentityPublicKey[], + encryptionKeys: UserIdentityPublicKey[], + links: Map, + ) { + this.details = details; + this.signingKeys = signingKeys; + this.encryptionKeys = encryptionKeys; + this.links = links; + } +} + +class UserIdentityMetadataDetails { + created: number; + createdBy: string; + + constructor(created: number, createdBy: string) { + this.created = created; + this.createdBy = createdBy; + } +} + +class UserIdentityPublicKey { + key: Uint8Array; + + constructor(key: Uint8Array) { + this.key = key; + } +} diff --git a/src/serialization/metadata/webapp.ts b/src/serialization/metadata/webapp.ts new file mode 100644 index 0000000..9314dc1 --- /dev/null +++ b/src/serialization/metadata/webapp.ts @@ -0,0 +1,114 @@ +import Metadata from "#serialization/metadata/base.js"; +import ExtraMetadata from "#serialization/metadata/extra.js"; +import CID from "#cid.js"; +import Unpacker from "#serialization/unpack.js"; +import { METADATA_TYPES, metadataMagicByte } from "#constants.js"; +import { Buffer } from "buffer"; + +export default class WebAppMetadata extends Metadata { + name: string | null; + tryFiles: string[]; + errorPages: Map; + extraMetadata: ExtraMetadata; + paths: { [key: string]: WebAppMetadataFileReference }; + + constructor( + name: string | null, + tryFiles: string[], + errorPages: Map, + paths: { [key: string]: WebAppMetadataFileReference }, + extraMetadata: ExtraMetadata, + ) { + super(); + this.name = name; + this.tryFiles = tryFiles; + this.errorPages = errorPages; + this.paths = paths; + this.extraMetadata = extraMetadata; + } + + toJson(): { [key: string]: any } { + return { + type: "web_app", + name: this.name, + tryFiles: this.tryFiles, + errorPages: Array.from(this.errorPages.entries()).reduce( + (obj, [key, value]) => { + obj[key.toString()] = value; + return obj; + }, + {} as { [key: string]: string }, + ), + paths: this.paths, + extraMetadata: this.extraMetadata, + }; + } +} + +class WebAppMetadataFileReference { + contentType: string | null; + cid: CID; + + constructor(cid: CID, contentType: string | null) { + this.cid = cid; + this.contentType = contentType; + } + + get size(): number { + return this.cid.size ?? 0; + } + + toJson(): { [key: string]: any } { + return { + cid: this.cid.toBase64Url(), + contentType: this.contentType, + }; + } +} + +export async function deserialize(bytes: Uint8Array): Promise { + const u = new Unpacker(Buffer.from(bytes)); + + const magicByte = u.unpackInt(); + if (magicByte !== metadataMagicByte) { + throw new Error("Invalid metadata: Unsupported magic byte"); + } + const typeAndVersion = u.unpackInt(); + if (typeAndVersion !== METADATA_TYPES.WEBAPP) { + throw new Error("Invalid metadata: Wrong metadata type"); + } + + u.unpackListLength(); + + const name = u.unpackString(); + + const tryFiles = u.unpackList() as string[]; + + const errorPages = u.unpackMap() as Record; + + const length = u.unpackListLength(); + + const paths: Record = {}; + + for (let i = 0; i < length; i++) { + u.unpackListLength(); + const path = u.unpackString(); + const cid = CID.fromBytes(u.unpackBinary()); + paths[path as string] = new WebAppMetadataFileReference( + cid, + u.unpackString(), + ); + } + + const extraMetadata = u.unpackMap() as Record; + + return new WebAppMetadata( + name, + tryFiles, + new Map( + Object.entries(errorPages).map(([key, value]) => [Number(key), value]), + ), + paths, + new ExtraMetadata(extraMetadata), + ); +}