├── .gitignore ├── tsconfig.json ├── test ├── model │ ├── utils.spec.ts │ ├── inbox.spec.ts │ ├── actor.spec.ts │ ├── message.spec.ts │ └── body.spec.ts ├── didkey.spec.ts ├── pageiter.spec.ts ├── signatures.spec.ts ├── messageiter.spec.ts ├── servers.spec.ts ├── storage.spec.ts └── chatternet.spec.ts ├── src ├── index.ts ├── ldcontexts │ ├── index.ts │ ├── ed25519-2020.ts │ ├── credentials.ts │ └── activitystreams.ts ├── utils.ts ├── model │ ├── index.ts │ ├── inbox.ts │ ├── utils.ts │ ├── actor.ts │ ├── document.ts │ └── messages.ts ├── messageiter.ts ├── didkey.ts ├── pageiter.ts ├── signatures.ts ├── servers.ts ├── storage.ts └── chatternet.ts ├── LICENSE ├── package.json ├── types ├── jsonld │ └── index.d.ts ├── @digitalbazaar__ed25519-signature-2020 │ └── index.d.ts ├── jsonld-signatures │ └── index.d.ts ├── @digitalbazaar__did-method-key │ └── index.d.ts ├── @digitalbazaar__ed25519-verification-key-2020 │ └── index.d.ts ├── crypto-ld │ └── index.d.ts └── @digitalbazaar_vc │ └── index.d.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/aegir/src/config/tsconfig.aegir.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "esModuleInterop": false, 6 | "baseUrl": "./", 7 | "paths": { 8 | "*": ["./types/*"] 9 | }, 10 | "target": "ES2020" 11 | }, 12 | "include": [ 13 | "src", 14 | "test", 15 | "types" 16 | ] 17 | } -------------------------------------------------------------------------------- /test/model/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { isUri } from "../../src/model/utils.js"; 2 | import * as assert from "assert"; 3 | 4 | describe("model utils", () => { 5 | it("guards URI", () => { 6 | assert.ok(!isUri("a")); 7 | assert.ok(isUri("a:")); 8 | assert.ok(isUri("a:b")); 9 | assert.ok(isUri("a:" + "b".repeat(2048 - 2))); 10 | assert.ok(!isUri("a:" + "b".repeat(2048 - 1))); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { ChatterNet } from "./chatternet.js"; 2 | export type { MessageDocuments as MessageBodies } from "./chatternet.js"; 3 | export * as Model from "./model/index.js"; 4 | export * as DidKey from "./didkey.js"; 5 | export type { IdName, ServerInfo as Server } from "./storage.js"; 6 | export { MessageIter } from "./messageiter.js"; 7 | export { PageIter } from "./pageiter.js"; 8 | export { Servers } from "./servers.js"; 9 | -------------------------------------------------------------------------------- /src/ldcontexts/index.ts: -------------------------------------------------------------------------------- 1 | import { activitystreams } from "./activitystreams.js"; 2 | import { credentials } from "./credentials.js"; 3 | import { ed25519_2020 } from "./ed25519-2020.js"; 4 | 5 | export const contexts: { [key: string]: object } = { 6 | [activitystreams.uri]: activitystreams.ctx, 7 | [credentials.uri]: credentials.ctx, 8 | [ed25519_2020.uri]: ed25519_2020.ctx, 9 | "https://w3id.org/security/suites/ed25519-2020/v1": ed25519_2020.ctx, 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function getTimestamp(value?: string | Date): number { 2 | const date = value ? new Date(value) : new Date(); 3 | return date.getTime() * 1e-3; 4 | } 5 | 6 | export function getIsoDate(value?: string | number | Date): string { 7 | const date = value ? new Date(typeof value === "number" ? value * 1e3 : value) : new Date(); 8 | return date.toISOString(); 9 | } 10 | 11 | export function orDefault(value: T | null, or: T): T { 12 | return value != null ? value : or; 13 | } 14 | -------------------------------------------------------------------------------- /src/model/index.ts: -------------------------------------------------------------------------------- 1 | export type { Actor } from "./actor.js"; 2 | export type { NoteMd1k, Tag30 } from "./document.js"; 3 | export type { Inbox } from "./inbox.js"; 4 | export type { Message } from "./messages.js"; 5 | export type { WithId } from "./utils.js"; 6 | export { newActor, isActor, verifyActor } from "./actor.js"; 7 | export { 8 | newNoteMd1k, 9 | isNoteMd1k, 10 | verifyNoteMd1k, 11 | newTag30, 12 | isTag30, 13 | verifyTag30, 14 | } from "./document.js"; 15 | export { newInbox } from "./inbox.js"; 16 | export { newMessage, isMessage, verifyMessage, getAudiences } from "./messages.js"; 17 | -------------------------------------------------------------------------------- /src/model/inbox.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from "./messages.js"; 2 | import { CONTEXT_SIG_STREAM, ContextSigStream, Uri } from "./utils.js"; 3 | 4 | export interface Inbox { 5 | "@context": ContextSigStream; 6 | id: Uri; 7 | type: "OrderedCollection"; 8 | items: Message[]; 9 | partOf: Uri; 10 | next?: Uri; 11 | } 12 | 13 | export function newInbox( 14 | actorId: string, 15 | messages: Message[], 16 | startIdx: number, 17 | pageSize: number, 18 | endIdx?: number 19 | ): Inbox { 20 | const partOf = `${actorId}/inbox`; 21 | const id = `${actorId}/inbox?startIdx=${startIdx}&pageSize=${pageSize}`; 22 | const next = 23 | endIdx != null ? `${actorId}/inbox?startIdx=${endIdx}&pageSize=${pageSize}` : undefined; 24 | return { 25 | "@context": CONTEXT_SIG_STREAM, 26 | id, 27 | type: "OrderedCollection", 28 | items: messages, 29 | partOf, 30 | next, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Garrin McGoldrick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/model/inbox.spec.ts: -------------------------------------------------------------------------------- 1 | import { DidKey, Model } from "../../src/index.js"; 2 | import * as assert from "assert"; 3 | 4 | describe("model inbox", () => { 5 | it("builds an inbox", async () => { 6 | const jwk = await DidKey.newKey(); 7 | const did = DidKey.didFromKey(jwk); 8 | const message = await Model.newMessage(did, ["urn:cid:a"], "Create", null, jwk); 9 | const inbox = Model.newInbox("did:example:a/actor", [message], 0, 3); 10 | assert.equal(inbox.id, "did:example:a/actor/inbox?startIdx=0&pageSize=3"); 11 | assert.equal(inbox.partOf, "did:example:a/actor/inbox"); 12 | assert.equal(inbox.items[0].id, message.id); 13 | assert.ok(!inbox.next); 14 | }); 15 | 16 | it("builds an inbox with next", async () => { 17 | const jwk = await DidKey.newKey(); 18 | const did = DidKey.didFromKey(jwk); 19 | const message = await Model.newMessage(did, ["urn:cid:a"], "Create", null, jwk); 20 | const inbox = Model.newInbox("did:example:a/actor", [message], 0, 3, 2); 21 | assert.equal(inbox.id, "did:example:a/actor/inbox?startIdx=0&pageSize=3"); 22 | assert.equal(inbox.next, "did:example:a/actor/inbox?startIdx=2&pageSize=3"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/didkey.spec.ts: -------------------------------------------------------------------------------- 1 | import { DidKey } from "../src/index.js"; 2 | import * as assert from "assert"; 3 | 4 | describe("did key", () => { 5 | const did = "did:key:z6MkqesEr2GVFXc3qWZi9PzMqtvMMyR5gB3P3R5GTsB7YTRC"; 6 | 7 | it("does not build fingerprint from invalid did", () => { 8 | assert.throws(() => DidKey.fingerprintFromDid("a:b")); 9 | }); 10 | 11 | it("builds fingerprint in a roundtrip", () => { 12 | assert.equal(DidKey.didFromFingerprint(DidKey.fingerprintFromDid(did)), did); 13 | }); 14 | 15 | it("builds peer ID and did in a roundtrip", async () => { 16 | const key = DidKey.keyFromDid(did); 17 | const didBack = DidKey.didFromKey(key); 18 | assert.equal(didBack, did); 19 | }); 20 | 21 | it("does not build key from invalid did", async () => { 22 | assert.throws(() => DidKey.keyFromDid("a:b")); 23 | }); 24 | 25 | it("signs and verifies with private key from peer id", async () => { 26 | const data = new Uint8Array([1, 2, 3]); 27 | const key = await DidKey.newKey(); 28 | const signer = DidKey.signerFromKey(key); 29 | const verifier = DidKey.verifierFromKey(key); 30 | const signature = await signer(data); 31 | assert.ok(await verifier(data, signature)); 32 | assert.ok(!(await verifier(new Uint8Array([]), signature))); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/messageiter.ts: -------------------------------------------------------------------------------- 1 | import * as Model from "./model/index.js"; 2 | import type { PageIter } from "./pageiter.js"; 3 | import type { DbPeer } from "./storage.js"; 4 | 5 | export class MessageIter { 6 | private pageNumber: number = 0; 7 | private localIdx: number | undefined = undefined; 8 | private localExhausted: boolean = false; 9 | 10 | constructor(readonly dbPeer: DbPeer, readonly pageIter: PageIter) {} 11 | 12 | getPageNumber(): number { 13 | return this.pageNumber; 14 | } 15 | 16 | async *messages(): AsyncGenerator { 17 | while (true) { 18 | if (this.localExhausted && this.pageIter.serverCursors.every((x) => x.exhausted)) break; 19 | 20 | // get from local first 21 | if (!this.localExhausted) { 22 | let pageOut = await this.dbPeer.message.getPage(this.localIdx, this.pageIter.pageSize); 23 | if (pageOut.nextStartIdx == null || pageOut.ids.length <= 0) this.localExhausted = true; 24 | this.localIdx = pageOut.nextStartIdx; 25 | for (const messageId of pageOut.ids) { 26 | if (this.pageIter.skipIds.has(messageId)) continue; 27 | // db stores message IDs separate from message object 28 | const message = (await this.dbPeer.document.get(messageId)) as Model.Message; 29 | if (!message) continue; 30 | this.pageIter.skipIds.add(messageId); 31 | yield message; 32 | } 33 | } 34 | 35 | // then get messages from servers 36 | for await (const message of this.pageIter.pageItems()) { 37 | if (!(await Model.verifyMessage(message))) continue; 38 | yield message; 39 | } 40 | 41 | this.pageNumber += 1; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/model/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Proof } from "../signatures.js"; 2 | import { get, has } from "lodash-es"; 3 | 4 | const MAX_URI_BYTES = 2048; 5 | 6 | export interface WithId { 7 | id: string; 8 | } 9 | export function isWithId(x: unknown): x is WithId { 10 | if (!has(x, "id")) return false; 11 | return true; 12 | } 13 | 14 | export interface WithProof { 15 | proof: Proof; 16 | } 17 | 18 | export type Uri = string; 19 | export function isUri(x: unknown): x is Uri { 20 | if (typeof x !== "string") return false; 21 | if (x.length > MAX_URI_BYTES) return false; 22 | if (!x.includes(":")) return false; 23 | return true; 24 | } 25 | 26 | export type ContextActivityStream = "https://www.w3.org/ns/activitystreams"; 27 | export type ContextSignature = "https://w3id.org/security/suites/ed25519-2020/v1"; 28 | export type ContextStream = [ContextActivityStream]; 29 | export const CONTEXT_STREAM: ContextStream = ["https://www.w3.org/ns/activitystreams"]; 30 | export function isContextStream(x: unknown): x is ContextSigStream { 31 | if (get(x, "length") != 1) return false; 32 | if (get(x, 0) !== CONTEXT_STREAM[0]) return false; 33 | return true; 34 | } 35 | export type ContextSigStream = [ContextSignature, ContextActivityStream]; 36 | export const CONTEXT_SIG_STREAM: ContextSigStream = [ 37 | "https://w3id.org/security/suites/ed25519-2020/v1", 38 | "https://www.w3.org/ns/activitystreams", 39 | ]; 40 | export function isContextSigStream(x: unknown): x is ContextSigStream { 41 | if (get(x, "length") != 2) return false; 42 | if (get(x, 0) !== CONTEXT_SIG_STREAM[0]) return false; 43 | if (get(x, 1) !== CONTEXT_SIG_STREAM[1]) return false; 44 | return true; 45 | } 46 | 47 | export function isIterable(x: unknown): x is Iterable { 48 | if (x == null) return false; 49 | if (typeof x !== "object") return false; 50 | return Symbol.iterator in x; 51 | } 52 | -------------------------------------------------------------------------------- /src/model/actor.ts: -------------------------------------------------------------------------------- 1 | import type { WithProof } from "../signatures.js"; 2 | import { DateTime, Key, isDateTime, sign, verify } from "../signatures.js"; 3 | import { getIsoDate } from "../utils.js"; 4 | import { CONTEXT_SIG_STREAM, ContextSigStream, Uri, isContextSigStream, isUri } from "./utils.js"; 5 | import { get, has } from "lodash-es"; 6 | 7 | const MAX_NAME_CHARS = 30; 8 | 9 | export interface ActorNoProof { 10 | "@context": ContextSigStream; 11 | id: Uri; 12 | type: string; 13 | published: DateTime; 14 | name?: string; 15 | url?: string; 16 | } 17 | 18 | export type Actor = ActorNoProof & WithProof; 19 | 20 | export interface ActorOptions { 21 | name?: string; 22 | url?: string; 23 | } 24 | 25 | export async function newActor( 26 | did: Uri, 27 | type: string, 28 | key: Key, 29 | options: ActorOptions = {} 30 | ): Promise { 31 | const id = `${did}/actor`; 32 | const actor: ActorNoProof = { 33 | "@context": CONTEXT_SIG_STREAM, 34 | id, 35 | type, 36 | published: getIsoDate(), 37 | ...options, 38 | }; 39 | return await sign(actor, key); 40 | } 41 | 42 | export function didFromActorId(actorId: string): string | undefined { 43 | const [did, path] = actorId.split("/", 2); 44 | if (path != "actor") return undefined; 45 | if (!did.startsWith("did:")) return undefined; 46 | return did; 47 | } 48 | 49 | export function isActor(x: unknown): x is Actor { 50 | if (!isContextSigStream(get(x, "@context"))) return false; 51 | if (!isUri(get(x, "id"))) return false; 52 | if (!has(x, "type")) return false; 53 | if (!isDateTime(get(x, "published"))) return false; 54 | const name: unknown = get(x, "name"); 55 | if (name != null && (typeof name !== "string" || name.split("").length > MAX_NAME_CHARS)) 56 | return false; 57 | return true; 58 | } 59 | 60 | export async function verifyActor(actor: Actor): Promise { 61 | const did = didFromActorId(actor.id); 62 | if (!did) return false; 63 | if (!(await verify(actor, did))) return false; 64 | return true; 65 | } 66 | -------------------------------------------------------------------------------- /test/model/actor.spec.ts: -------------------------------------------------------------------------------- 1 | import { DidKey, Model } from "../../src/index.js"; 2 | import { didFromActorId } from "../../src/model/actor.js"; 3 | import * as assert from "assert"; 4 | import { omit } from "lodash-es"; 5 | 6 | describe("model actor", () => { 7 | const did = "did:key:z6MkqesEr2GVFXc3qWZi9PzMqtvMMyR5gB3P3R5GTsB7YTRC"; 8 | 9 | it("builds did from actor id", () => { 10 | const actorId = `${did}/actor`; 11 | const didBack = didFromActorId(actorId); 12 | assert.equal(did, didBack); 13 | }); 14 | 15 | it("doesnt build did from invalid actor id", () => { 16 | assert.ok(!didFromActorId(`${did}/other`)); 17 | assert.ok(!didFromActorId(`${did}`)); 18 | assert.ok(!didFromActorId(`a:b/actor`)); 19 | }); 20 | 21 | it("builds and verifies an actor", async () => { 22 | const jwk = await DidKey.newKey(); 23 | const did = DidKey.didFromKey(jwk); 24 | const actor = await Model.newActor(did, "Person", jwk, { 25 | name: "abc", 26 | }); 27 | assert.ok(await Model.verifyActor(actor)); 28 | assert.ok(actor.id.startsWith(did)); 29 | assert.equal(actor.name, "abc"); 30 | }); 31 | 32 | it("guards actor", async () => { 33 | const jwk = await DidKey.newKey(); 34 | const did = DidKey.didFromKey(jwk); 35 | const actor = await Model.newActor(did, "Person", jwk, { 36 | name: "abc", 37 | }); 38 | assert.ok(Model.isActor(actor)); 39 | assert.ok(Model.isActor(omit(actor, "name"))); 40 | assert.ok(Model.isActor(omit(actor, "url"))); 41 | assert.ok(!Model.isActor(omit(actor, "@context"))); 42 | assert.ok(!Model.isActor(omit(actor, "id"))); 43 | assert.ok(!Model.isActor(omit(actor, "type"))); 44 | assert.ok(!Model.isActor(omit(actor, "published"))); 45 | }); 46 | 47 | it("doesnt verify invalid actor", async () => { 48 | const jwk = await DidKey.newKey(); 49 | const did = DidKey.didFromKey(jwk); 50 | const actor = await Model.newActor(did, "Person", jwk, { name: "abc" }); 51 | assert.ok(!(await Model.verifyActor({ ...actor, name: "abcd" }))); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/didkey.ts: -------------------------------------------------------------------------------- 1 | import { Ed25519VerificationKey2020 } from "@digitalbazaar/ed25519-verification-key-2020"; 2 | import type { LDKeyPair } from "crypto-ld"; 3 | 4 | // Protocols: 5 | // https://www.w3.org/TR/did-core/ 6 | // https://w3c-ccg.github.io/did-method-key/ 7 | // https://github.com/multiformats/multicodec 8 | 9 | // Digital Bazaaar implementations: 10 | // https://github.com/digitalbazaar/did-method-key 11 | // https://github.com/digitalbazaar/crypto-ld 12 | // https://github.com/digitalbazaar/ed25519-verification-key-2020 13 | 14 | export async function newKey(): Promise { 15 | const key = await Ed25519VerificationKey2020.generate(); 16 | const did = didFromKey(key); 17 | const fingerprint = fingerprintFromDid(did); 18 | key.id = `${did}#${fingerprint}`; 19 | key.controller = did; 20 | return key; 21 | } 22 | 23 | export function fingerprintFromDid(did: string): string { 24 | if (!did.startsWith("did:key:")) throw Error("invalid did"); 25 | return did.slice("did:key:".length); 26 | } 27 | 28 | export function didFromFingerprint(fingerprint: string): string { 29 | return `did:key:${fingerprint}`; 30 | } 31 | 32 | export function keyFromDid(did: string): Ed25519VerificationKey2020 { 33 | const fingerprint = fingerprintFromDid(did); 34 | const key = Ed25519VerificationKey2020.fromFingerprint({ fingerprint }); 35 | key.id = `${did}#${fingerprint}`; 36 | key.controller = did; 37 | return key; 38 | } 39 | 40 | export function didFromKey(key: LDKeyPair): string { 41 | return didFromFingerprint(key.fingerprint()); 42 | } 43 | 44 | export type Verifier = (data: Uint8Array, signature: Uint8Array) => Promise; 45 | 46 | export function verifierFromKey(key: LDKeyPair): Verifier { 47 | const { verify } = key.verifier(); 48 | return async (data, signature) => verify({ data, signature }); 49 | } 50 | 51 | export type Signer = (data: Uint8Array) => Promise; 52 | 53 | export function signerFromKey(key: LDKeyPair): Signer { 54 | const { sign } = key.signer(); 55 | return async (data) => sign({ data }); 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatternet-client-http", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "homepage": "https://github.com/chatternet/chatternet-client-http#readme", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/chatternet/chatternet-client-http.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/chatternet/chatternet-client-http/issues" 12 | }, 13 | "type": "module", 14 | "types": "./dist/src/index.d.ts", 15 | "main": "src/index.js", 16 | "files": [ 17 | "src", 18 | "dist" 19 | ], 20 | "exports": { 21 | ".": { 22 | "types": "./src/index.d.ts", 23 | "import": "./dist/src/index.js" 24 | } 25 | }, 26 | "scripts": { 27 | "lint": "aegir lint", 28 | "release": "aegir release", 29 | "build": "aegir build", 30 | "test": "aegir test --target node", 31 | "clean": "rm -rf dist/", 32 | "fmt": "prettier -w '{test,src,types}/**/*.{js,ts}'" 33 | }, 34 | "devDependencies": { 35 | "@trivago/prettier-plugin-sort-imports": "3.4.0", 36 | "@types/lodash-es": "4.17.6", 37 | "aegir": "37.5.6", 38 | "assert": "2.0.0", 39 | "fake-indexeddb": "4.0.0", 40 | "fetch-mock": "9.11.0", 41 | "jest-fetch-mock": "3.0.3", 42 | "mock-local-storage": "1.1.23", 43 | "prettier": "2.7.1" 44 | }, 45 | "dependencies": { 46 | "@digitalbazaar/did-method-key": "3.0.0", 47 | "@digitalbazaar/ed25519-signature-2020": "5.0.0", 48 | "@digitalbazaar/ed25519-verification-key-2020": "4.1.0", 49 | "@digitalbazaar/vc": "5.0.0", 50 | "crypto-ld": "7.0.0", 51 | "events": "3.3.0", 52 | "idb": "7.1.1", 53 | "jsonld": "8.1.0", 54 | "jsonld-signatures": "11.0.0", 55 | "lodash-es": "4.17.21", 56 | "multiformats": "10.0.2" 57 | }, 58 | "overrides": { 59 | "security-context": "https://registry.npmjs.org/@docknetwork/security-context/-/security-context-4.0.1-0.tgz" 60 | }, 61 | "aegir": { 62 | "tsRepo": true 63 | }, 64 | "prettier": { 65 | "importOrderSortSpecifiers": true, 66 | "printWidth": 100 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/pageiter.ts: -------------------------------------------------------------------------------- 1 | import type { PageOut, Servers } from "./servers.js"; 2 | import { get } from "lodash-es"; 3 | 4 | interface ServerCursor { 5 | url: string; 6 | startIdx: number | undefined; 7 | exhausted: boolean; 8 | } 9 | 10 | /** 11 | * Iterate over paged resources from multiple servers. 12 | */ 13 | export class PageIter { 14 | constructor( 15 | /** The resource to pull from the servers. */ 16 | readonly uri: string, 17 | /** The servers to pull from. */ 18 | readonly servers: Servers, 19 | /** The amount of items to pull in one request from each server. */ 20 | readonly pageSize: number, 21 | /** Type guard to restrict items to the type T. */ 22 | readonly guard: (x: unknown) => x is T, 23 | /** Tracks pagination per server. */ 24 | readonly serverCursors: ServerCursor[], 25 | /** Tracks IDs of items seen by this iterator. */ 26 | readonly skipIds: Set 27 | ) {} 28 | 29 | static new( 30 | uri: string, 31 | servers: Servers, 32 | pageSize: number, 33 | guard: (x: unknown) => x is T 34 | ): PageIter { 35 | const cursors = [...servers.urlsServer.values()].map((x) => ({ 36 | url: x.url, 37 | startIdx: undefined, 38 | exhausted: false, 39 | })); 40 | return new PageIter(uri, servers, pageSize, guard, cursors, new Set()); 41 | } 42 | 43 | async *pageItems(): AsyncGenerator { 44 | const numServers = this.serverCursors.length; 45 | for (let serverIdx = 0; serverIdx < numServers; serverIdx++) { 46 | const { url, startIdx, exhausted } = this.serverCursors[serverIdx]; 47 | if (exhausted) continue; 48 | let out: PageOut | undefined = undefined; 49 | try { 50 | out = await this.servers.getPaginated(this.uri, url, startIdx, this.pageSize); 51 | } catch {} 52 | if ( 53 | out == null || 54 | out.items.length <= 0 || 55 | out.nextStartIdx == null || 56 | out.nextStartIdx == startIdx 57 | ) 58 | this.serverCursors[serverIdx].exhausted = true; 59 | if (out == null) continue; 60 | this.serverCursors[serverIdx].startIdx = out.nextStartIdx; 61 | for (const item of out.items) { 62 | const id = typeof item === "string" ? item : get(item, "id"); 63 | if (typeof id === "string") { 64 | if (this.skipIds.has(id)) continue; 65 | this.skipIds.add(id); 66 | } 67 | if (!this.guard(item)) continue; 68 | yield item; 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/pageiter.spec.ts: -------------------------------------------------------------------------------- 1 | import { PageIter } from "../src/pageiter.js"; 2 | import { Servers } from "../src/servers.js"; 3 | import * as assert from "assert"; 4 | 5 | describe("page iter", () => { 6 | it("iterates pages from multiple servers", async () => { 7 | const servers = Servers.fromInfos([ 8 | { url: "http://a.example", did: "did:example:a" }, 9 | { url: "http://b.example", did: "did:example:b" }, 10 | ]); 11 | 12 | servers.getPaginated = async ( 13 | uri: string, 14 | serverUrl: string, 15 | startIdx?: number, 16 | pageSize?: number 17 | ) => { 18 | pageSize = pageSize ? pageSize : 1; 19 | if (!uri.startsWith("resource")) throw Error("invalid resource"); 20 | 21 | if (serverUrl === "http://a.example") { 22 | let items = []; 23 | if (startIdx == null || startIdx === 3) items = ["a3", "a2", "a1"]; 24 | else if (startIdx === 2) items = ["a2", "a2"]; 25 | else if (startIdx === 1) items = ["a1"]; 26 | else throw Error("invalid start idx"); 27 | 28 | if (pageSize != null) items = items.slice(0, +pageSize); 29 | 30 | let nextStartIdx = undefined; 31 | if (items[items.length - 1] == "a3") nextStartIdx = 2; 32 | if (items[items.length - 1] == "a2") nextStartIdx = 1; 33 | 34 | return { items, nextStartIdx }; 35 | } else if (serverUrl === "http://b.example") { 36 | let items = []; 37 | if (startIdx == null || startIdx === 2) items = ["b2", "b1"]; 38 | else if (startIdx === 1) items = ["b1"]; 39 | else throw Error("invalid start idx"); 40 | 41 | if (pageSize != null) items = items.slice(0, +pageSize); 42 | 43 | let nextStartIdx = undefined; 44 | if (items[items.length - 1] == "b2") nextStartIdx = 1; 45 | 46 | return { items, nextStartIdx }; 47 | } else throw Error("invalid server URL"); 48 | }; 49 | 50 | const uri = "resource"; 51 | const isString = function (x: unknown): x is string { 52 | return typeof x === "string"; 53 | }; 54 | const pageIter = PageIter.new(uri, servers, 2, isString); 55 | 56 | const page1: string[] = []; 57 | const page2: string[] = []; 58 | for await (const item of pageIter.pageItems()) { 59 | page1.push(item); 60 | } 61 | for await (const item of pageIter.pageItems()) { 62 | page2.push(item); 63 | } 64 | assert.deepEqual(page1, ["a3", "a2", "b2", "b1"]); 65 | assert.deepEqual(page2, ["a1"]); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/signatures.spec.ts: -------------------------------------------------------------------------------- 1 | import { DidKey } from "../src/index.js"; 2 | import * as Signatures from "../src/signatures.js"; 3 | import * as assert from "assert"; 4 | 5 | describe("credentials", () => { 6 | it("builds same CID for same doc", async () => { 7 | const doc1 = { 8 | "@context": "https://www.w3.org/ns/activitystreams", 9 | content: "abc", 10 | }; 11 | const doc2 = { 12 | "@context": "https://www.w3.org/ns/activitystreams", 13 | "https://www.w3.org/ns/activitystreams#content": "abc", 14 | }; 15 | const id1 = await Signatures.buildDocCid(doc1); 16 | const id2 = await Signatures.buildDocCid(doc2); 17 | assert.ok(id1.equals(id2)); 18 | }); 19 | 20 | it("builds different CID for different documents", async () => { 21 | const doc1 = { 22 | "@context": "https://www.w3.org/ns/activitystreams", 23 | content: "abc", 24 | }; 25 | const doc2 = { 26 | "@context": "https://www.w3.org/ns/activitystreams", 27 | content: "abcd", 28 | }; 29 | const id1 = await Signatures.buildDocCid(doc1); 30 | const id2 = await Signatures.buildDocCid(doc2); 31 | assert.ok(!id1.equals(id2)); 32 | }); 33 | 34 | it("signs and verifies a document", async () => { 35 | const key = await DidKey.newKey(); 36 | const did = DidKey.didFromKey(key); 37 | const doc = { 38 | "@context": [ 39 | "https://www.w3.org/ns/activitystreams", 40 | "https://www.w3.org/2018/credentials/v1", 41 | ], 42 | content: "abc", 43 | }; 44 | const signed = await Signatures.sign(doc, key); 45 | const verified = await Signatures.verify(signed, did); 46 | assert.ok(verified); 47 | }); 48 | 49 | it("doesnt verify modified document", async () => { 50 | const key = await DidKey.newKey(); 51 | const did = DidKey.didFromKey(key); 52 | const doc = { 53 | "@context": [ 54 | "https://www.w3.org/ns/activitystreams", 55 | "https://www.w3.org/2018/credentials/v1", 56 | ], 57 | content: "abc", 58 | }; 59 | const signed = await Signatures.sign(doc, key); 60 | signed.content = "abcd"; 61 | const verified = await Signatures.verify(signed, did); 62 | assert.ok(!verified); 63 | }); 64 | 65 | it("doesnt sign arbitrary data", async () => { 66 | const key = await DidKey.newKey(); 67 | const doc = { 68 | "@context": [ 69 | "https://www.w3.org/ns/activitystreams", 70 | "https://www.w3.org/2018/credentials/v1", 71 | ], 72 | content: "abc", 73 | "invalid key": "abc", 74 | }; 75 | assert.rejects(Signatures.sign(doc, key)); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/model/message.spec.ts: -------------------------------------------------------------------------------- 1 | import { DidKey, Model } from "../../src/index.js"; 2 | import { sign } from "../../src/signatures.js"; 3 | import * as assert from "assert"; 4 | import { omit } from "lodash-es"; 5 | 6 | describe("model message", () => { 7 | it("builds and verifies a message", async () => { 8 | const jwk = await DidKey.newKey(); 9 | const did = DidKey.didFromKey(jwk); 10 | const message = await Model.newMessage(did, ["urn:cid:a"], "Create", null, jwk, { 11 | to: ["did:example:a"], 12 | }); 13 | assert.ok(await Model.verifyMessage(message)); 14 | assert.ok(message.actor.startsWith(did)); 15 | assert.equal(message.object, "urn:cid:a"); 16 | assert.equal(message.to, "did:example:a"); 17 | }); 18 | 19 | it("doesnt verify modified message", async () => { 20 | const jwk = await DidKey.newKey(); 21 | const did = DidKey.didFromKey(jwk); 22 | const message = await Model.newMessage(did, ["urn:cid:a"], "Create", null, jwk, { 23 | to: ["did:example:a"], 24 | }); 25 | message.to = ["did:example:b"]; 26 | assert.ok(!(await Model.verifyMessage(message))); 27 | }); 28 | 29 | it("doesnt verify message with invalid ID", async () => { 30 | const jwk = await DidKey.newKey(); 31 | const did = DidKey.didFromKey(jwk); 32 | const message = await Model.newMessage(did, ["urn:cid:a"], "Create", null, jwk, { 33 | to: ["did:example:a"], 34 | }); 35 | let invalid = await sign({ ...omit(message, "proof"), id: "urn:cid:a" }, jwk); 36 | assert.ok(!(await Model.verifyMessage(invalid))); 37 | }); 38 | 39 | it("guards message with id", async () => { 40 | const jwk = await DidKey.newKey(); 41 | const did = DidKey.didFromKey(jwk); 42 | const message = await Model.newMessage(did, ["urn:cid:a"], "Create", null, jwk); 43 | assert.ok(Model.isMessage(message)); 44 | assert.ok(!Model.isMessage(omit(message, "@context"))); 45 | assert.ok(!Model.isMessage(omit(message, "id"))); 46 | assert.ok(!Model.isMessage(omit(message, "type"))); 47 | assert.ok(!Model.isMessage(omit(message, "actor"))); 48 | assert.ok(!Model.isMessage(omit(message, "object"))); 49 | assert.ok(!Model.isMessage(omit(message, "published"))); 50 | }); 51 | 52 | it("gets message audiences", async () => { 53 | const jwk = await DidKey.newKey(); 54 | const did = DidKey.didFromKey(jwk); 55 | const message = await Model.newMessage(did, ["urn:cid:a"], "Create", null, jwk, { 56 | to: ["a:b/followers", "a:c/followers"], 57 | }); 58 | const audiences = new Set(Model.getAudiences(message)); 59 | assert.deepEqual(audiences, new Set(["a:b/followers", "a:c/followers"])); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/model/body.spec.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "../../src/index.js"; 2 | import { CONTEXT_STREAM } from "../../src/model/utils.js"; 3 | import * as assert from "assert"; 4 | import { omit } from "lodash-es"; 5 | 6 | describe("model body", () => { 7 | it("guards note", async () => { 8 | const objectDoc = { 9 | "@context": CONTEXT_STREAM, 10 | id: "a:b", 11 | type: "Note", 12 | content: "abcd", 13 | mediaType: "text/markdown", 14 | attributedTo: "did:example:a", 15 | }; 16 | assert.ok(Model.isNoteMd1k(objectDoc)); 17 | assert.ok(!Model.isNoteMd1k(omit(objectDoc, "@context"))); 18 | assert.ok(!Model.isNoteMd1k(omit(objectDoc, "id"))); 19 | assert.ok(!Model.isNoteMd1k(omit(objectDoc, "type"))); 20 | assert.ok(!Model.isNoteMd1k(omit(objectDoc, "content"))); 21 | assert.ok(!Model.isNoteMd1k(omit(objectDoc, "mediaType"))); 22 | assert.ok(!Model.isNoteMd1k(omit(objectDoc, "attributedTo"))); 23 | }); 24 | 25 | it("builds and verifies a note", async () => { 26 | const objectDoc = await Model.newNoteMd1k("abc", "did:example:a", { 27 | inReplyTo: "urn:cid:a", 28 | }); 29 | assert.ok(await Model.verifyNoteMd1k(objectDoc)); 30 | }); 31 | 32 | it("doesnt build a note with content too long", async () => { 33 | assert.rejects(async () => await Model.newNoteMd1k("a".repeat(1024 + 1), "did:example:a")); 34 | }); 35 | 36 | it("doesnt verify a note with invalid content", async () => { 37 | const objectDoc = await Model.newNoteMd1k("abc", "did:example:a"); 38 | objectDoc.content = "abcd"; 39 | assert.ok(!(await Model.verifyNoteMd1k(objectDoc))); 40 | }); 41 | 42 | it("guards tag", async () => { 43 | const objectDoc = { 44 | "@context": CONTEXT_STREAM, 45 | id: "a:b", 46 | type: "Object", 47 | name: "abcd", 48 | }; 49 | assert.ok(Model.isTag30(objectDoc)); 50 | assert.ok(!Model.isTag30(omit(objectDoc, "@context"))); 51 | assert.ok(!Model.isTag30(omit(objectDoc, "id"))); 52 | assert.ok(!Model.isTag30(omit(objectDoc, "type"))); 53 | assert.ok(!Model.isTag30(omit(objectDoc, "name"))); 54 | }); 55 | 56 | it("builds and verifies a tag", async () => { 57 | const objectDoc = await Model.newTag30("abc"); 58 | assert.ok(await Model.verifyTag30(objectDoc)); 59 | }); 60 | 61 | it("doesnt build a tag with name too long", async () => { 62 | assert.rejects(async () => await Model.newTag30("a".repeat(30 + 1))); 63 | }); 64 | 65 | it("doesnt verify a tag with invalid content", async () => { 66 | const objectDoc = await Model.newTag30("abc"); 67 | objectDoc.name = "abcd"; 68 | assert.ok(!(await Model.verifyTag30(objectDoc))); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/signatures.ts: -------------------------------------------------------------------------------- 1 | import { keyFromDid } from "./didkey.js"; 2 | import { contexts } from "./ldcontexts/index.js"; 3 | import { DidKeyDriver } from "@digitalbazaar/did-method-key"; 4 | import { Ed25519Signature2020 } from "@digitalbazaar/ed25519-signature-2020"; 5 | import type { Ed25519VerificationKey2020 } from "@digitalbazaar/ed25519-verification-key-2020"; 6 | import jsonld from "jsonld"; 7 | import jsigs from "jsonld-signatures"; 8 | import { get } from "lodash-es"; 9 | import { CID } from "multiformats"; 10 | import * as json from "multiformats/codecs/json"; 11 | import type { MultihashDigest } from "multiformats/hashes/interface.js"; 12 | import { sha256 } from "multiformats/hashes/sha2"; 13 | 14 | export type Key = Ed25519VerificationKey2020; 15 | 16 | export type DateTime = string; 17 | export function isDateTime(x: unknown): x is DateTime { 18 | if (typeof x !== "string") return false; 19 | let date1 = new Date(x); 20 | let date2 = new Date(date1.toISOString()); 21 | return date1.getTime() === date2.getTime(); 22 | } 23 | 24 | export interface Proof { 25 | type: string; 26 | created: DateTime; 27 | verificationMethod: string; 28 | proofPurpose: string; 29 | proofValue: string; 30 | } 31 | 32 | export function buildDocumentLoader(urlToDocument?: { 33 | [url: string]: object; 34 | }): (url: string) => { document: object } { 35 | return (url: string) => { 36 | let document = undefined; 37 | if (urlToDocument) document = get(urlToDocument, url); 38 | if (!document) document = get(contexts, url); 39 | if (!document) throw Error(`document contains an unknown url: ${url}`); 40 | return { document }; 41 | }; 42 | } 43 | 44 | async function canonize(doc: object): Promise { 45 | const documentLoader = buildDocumentLoader(); 46 | return jsonld.canonize(doc, { 47 | algorithm: "URDNA2015", 48 | format: "application/n-quads", 49 | documentLoader, 50 | }); 51 | } 52 | 53 | async function buildDigest(doc: object): Promise { 54 | const canonized = await canonize(doc); 55 | if (!canonized) throw Error("unable to build digest"); 56 | const bytes = new TextEncoder().encode(canonized); 57 | return await sha256.digest(bytes); 58 | } 59 | 60 | export async function buildDocCid(doc: object): Promise { 61 | return CID.createV1(json.code, await buildDigest(doc)); 62 | } 63 | 64 | export interface WithProof { 65 | proof: Proof; 66 | } 67 | 68 | export async function sign(document: T, key: Key): Promise { 69 | const suite = new Ed25519Signature2020({ key }); 70 | const documentLoader = buildDocumentLoader(); 71 | const purpose = new jsigs.purposes.AssertionProofPurpose(); 72 | return await jsigs.sign(document, { suite, purpose, documentLoader }); 73 | } 74 | 75 | export async function verify(document: object, did: string): Promise { 76 | const key = keyFromDid(did); 77 | const suite = new Ed25519Signature2020({ key }); 78 | const didDocument = await new DidKeyDriver().get({ did, url: undefined }); 79 | const documentLoader = buildDocumentLoader({ [did]: didDocument }); 80 | const purpose = new jsigs.purposes.AssertionProofPurpose(); 81 | const { verified } = await jsigs.verify(document, { suite, purpose, documentLoader }); 82 | return verified; 83 | } 84 | -------------------------------------------------------------------------------- /types/jsonld/index.d.ts: -------------------------------------------------------------------------------- 1 | const jsonld = { 2 | /** 3 | * A JavaScript implementation of the JSON-LD API. 4 | * 5 | * @author Dave Longley 6 | * 7 | * @license BSD 3-Clause License 8 | * Copyright (c) 2011-2019 Digital Bazaar, Inc. 9 | * All rights reserved. 10 | * 11 | * Redistribution and use in source and binary forms, with or without 12 | * modification, are permitted provided that the following conditions are met: 13 | * 14 | * Redistributions of source code must retain the above copyright notice, 15 | * this list of conditions and the following disclaimer. 16 | * 17 | * Redistributions in binary form must reproduce the above copyright 18 | * notice, this list of conditions and the following disclaimer in the 19 | * documentation and/or other materials provided with the distribution. 20 | * 21 | * Neither the name of the Digital Bazaar, Inc. nor the names of its 22 | * contributors may be used to endorse or promote products derived from 23 | * this software without specific prior written permission. 24 | * 25 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 26 | * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 27 | * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 28 | * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 29 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 30 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 31 | * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 32 | * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 33 | * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 34 | * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 35 | * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 36 | */ 37 | 38 | /** 39 | * Performs RDF dataset normalization on the given input. The input is JSON-LD 40 | * unless the 'inputFormat' option is used. The output is an RDF dataset 41 | * unless the 'format' option is used. 42 | * 43 | * @param input the input to normalize as JSON-LD or as a format specified by 44 | * the 'inputFormat' option. 45 | * @param [options] the options to use: 46 | * [algorithm] the normalization algorithm to use, `URDNA2015` or 47 | * `URGNA2012` (default: `URDNA2015`). 48 | * [base] the base IRI to use. 49 | * [expandContext] a context to expand with. 50 | * [skipExpansion] true to assume the input is expanded and skip 51 | * expansion, false not to, defaults to false. 52 | * [inputFormat] the format if input is not JSON-LD: 53 | * 'application/n-quads' for N-Quads. 54 | * [format] the format if output is a string: 55 | * 'application/n-quads' for N-Quads. 56 | * [documentLoader(url, options)] the document loader. 57 | * [useNative] true to use a native canonize algorithm 58 | * [contextResolver] internal use only. 59 | * 60 | * @return a Promise that resolves to the normalized output. 61 | */ 62 | canonize: async(input, options), 63 | }; 64 | 65 | export default jsonld; 66 | -------------------------------------------------------------------------------- /types/@digitalbazaar__ed25519-signature-2020/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@digitalbazaar/ed25519-signature-2020" { 2 | /*! 3 | * Copyright (c) 2020-2021 Digital Bazaar, Inc. All rights reserved. 4 | */ 5 | export class Ed25519Signature2020 extends LinkedDataSignature { 6 | /** 7 | * @param {object} options - Options hashmap. 8 | * 9 | * Either a `key` OR at least one of `signer`/`verifier` is required: 10 | * 11 | * @param {object} [options.key] - An optional key object (containing an 12 | * `id` property, and either `signer` or `verifier`, depending on the 13 | * intended operation. Useful for when the application is managing keys 14 | * itself (when using a KMS, you never have access to the private key, 15 | * and so should use the `signer` param instead). 16 | * @param {Function} [options.signer] - Signer function that returns an 17 | * object with an async sign() method. This is useful when interfacing 18 | * with a KMS (since you don't get access to the private key and its 19 | * `signer()`, the KMS client gives you only the signer function to use). 20 | * @param {Function} [options.verifier] - Verifier function that returns 21 | * an object with an async `verify()` method. Useful when working with a 22 | * KMS-provided verifier function. 23 | * 24 | * Advanced optional parameters and overrides: 25 | * 26 | * @param {object} [options.proof] - A JSON-LD document with options to use 27 | * for the `proof` node (e.g. any other custom fields can be provided here 28 | * using a context different from security-v2). 29 | * @param {string|Date} [options.date] - Signing date to use if not passed. 30 | * @param {boolean} [options.useNativeCanonize] - Whether to use a native 31 | * canonize algorithm. 32 | */ 33 | constructor({}); 34 | 35 | /** 36 | * Adds a signature (proofValue) field to the proof object. Called by 37 | * LinkedDataSignature.createProof(). 38 | * 39 | * @param {object} options - The options to use. 40 | * @param {Uint8Array} options.verifyData - Data to be signed (extracted 41 | * from document, according to the suite's spec). 42 | * @param {object} options.proof - Proof object (containing the proofPurpose, 43 | * verificationMethod, etc). 44 | * 45 | * @returns {Promise} Resolves with the proof containing the signature 46 | * value. 47 | */ 48 | async sign({ verifyData, proof }); 49 | 50 | /** 51 | * Verifies the proof signature against the given data. 52 | * 53 | * @param {object} options - The options to use. 54 | * @param {Uint8Array} options.verifyData - Canonicalized hashed data. 55 | * @param {object} options.verificationMethod - Key object. 56 | * @param {object} options.proof - The proof to be verified. 57 | * 58 | * @returns {Promise} Resolves with the verification result. 59 | */ 60 | async verifySignature({ verifyData, verificationMethod, proof }); 61 | 62 | async assertVerificationMethod({ verificationMethod }); 63 | 64 | async getVerificationMethod({ proof, documentLoader }); 65 | 66 | async matchProof({ proof, document, purpose, documentLoader, expansionMap }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/messageiter.spec.ts: -------------------------------------------------------------------------------- 1 | import * as DidKey from "../src/didkey.js"; 2 | import { MessageIter } from "../src/messageiter.js"; 3 | import * as Model from "../src/model/index.js"; 4 | import { PageIter } from "../src/pageiter.js"; 5 | import { Servers } from "../src/servers.js"; 6 | import { DbPeer } from "../src/storage.js"; 7 | import * as assert from "assert"; 8 | 9 | describe("message iter", () => { 10 | it("iterates messages and returns undefined when done", async () => { 11 | const key = await DidKey.newKey(); 12 | const actorDid = DidKey.didFromKey(key); 13 | 14 | const messagesA = [ 15 | await Model.newMessage(actorDid, ["urn:cid:a1"], "Create", null, key), 16 | await Model.newMessage(actorDid, ["urn:cid:a2"], "Create", null, key), 17 | await Model.newMessage(actorDid, ["urn:cid:a3"], "Create", null, key), 18 | ]; 19 | 20 | const messagesLocal = [ 21 | await Model.newMessage(actorDid, ["urn:cid:l1"], "Create", null, key), 22 | await Model.newMessage(actorDid, ["urn:cid:l2"], "Create", null, key), 23 | await Model.newMessage(actorDid, ["urn:cid:l3"], "Create", null, key), 24 | ]; 25 | 26 | const dbPeer = await DbPeer.new(); 27 | for (const messageLocal of messagesLocal) { 28 | await dbPeer.document.put(messageLocal); 29 | await dbPeer.message.put(messageLocal.id); 30 | } 31 | 32 | const servers = Servers.fromInfos([{ url: "http://a.example", did: "did:example:a" }]); 33 | 34 | servers.getPaginated = async ( 35 | uri: string, 36 | serverUrl: string, 37 | startIdx?: number, 38 | pageSize?: number 39 | ) => { 40 | pageSize = pageSize ? pageSize : 1; 41 | if (serverUrl !== "http://a.example") throw Error("server URL is not known"); 42 | if (uri.startsWith(`${actorDid}/actor/inbox`)) { 43 | startIdx = startIdx ? startIdx : 3; 44 | const nextStartIdx = startIdx - pageSize > 0 ? startIdx - pageSize : undefined; 45 | if (startIdx === 3) 46 | return { items: [...messagesA].reverse().slice(0, pageSize), nextStartIdx }; 47 | else if (startIdx === 2) 48 | return { items: [...messagesA].reverse().slice(1, 1 + pageSize), nextStartIdx }; 49 | else if (startIdx === 1) 50 | return { items: [...messagesA].reverse().slice(2, 2 + pageSize), nextStartIdx }; 51 | else return { items: [] }; 52 | } else { 53 | return { items: [] }; 54 | } 55 | }; 56 | 57 | const uri = `${actorDid}/actor/inbox`; 58 | const pageIter = PageIter.new(uri, servers, 2, Model.isMessage); 59 | 60 | const messageIter = new MessageIter(dbPeer, pageIter); 61 | const objectsId: [string, number][] = []; 62 | for await (const message of messageIter.messages()) { 63 | objectsId.push([message.object[0], messageIter.getPageNumber()]); 64 | } 65 | assert.deepEqual(objectsId, [ 66 | // local messages first in reverse order 67 | ["urn:cid:l3", 0], 68 | ["urn:cid:l2", 0], 69 | // first page of server a (order sent by server) 70 | ["urn:cid:a3", 0], 71 | ["urn:cid:a2", 0], 72 | // second page of server a (order sent by server) 73 | // num cycles increases due to first full cycle 74 | ["urn:cid:l1", 1], 75 | ["urn:cid:a1", 1], 76 | ]); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/model/document.ts: -------------------------------------------------------------------------------- 1 | import { buildDocCid } from "../signatures.js"; 2 | import { CONTEXT_STREAM, ContextStream, Uri, WithId, isContextStream, isUri } from "./utils.js"; 3 | import { get, omit } from "lodash-es"; 4 | 5 | interface NoteMd1kNoId { 6 | "@context": ContextStream; 7 | type: "Note"; 8 | content: string; 9 | mediaType: "text/markdown"; 10 | attributedTo: Uri; 11 | inReplyTo?: Uri; 12 | } 13 | 14 | export interface NoteMd1kOptions { 15 | inReplyTo?: string; 16 | } 17 | 18 | export async function newNoteMd1k( 19 | content: string, 20 | attributedTo: string, 21 | options: NoteMd1kOptions = {} 22 | ): Promise { 23 | if (new TextEncoder().encode(content).length > 1024) throw new Error("Content too long"); 24 | const document: NoteMd1kNoId = { 25 | "@context": CONTEXT_STREAM, 26 | type: "Note", 27 | content, 28 | mediaType: "text/markdown", 29 | attributedTo, 30 | ...options, 31 | }; 32 | const cid = (await buildDocCid(document)).toString(); 33 | const id = `urn:cid:${cid}`; 34 | return { id, ...document }; 35 | } 36 | 37 | export async function verifyNoteMd1k(document: NoteMd1k): Promise { 38 | const objectDocNoId = omit(document, ["id"]); 39 | const cid = (await buildDocCid(objectDocNoId)).toString(); 40 | if (`urn:cid:${cid}` !== document.id) return false; 41 | return true; 42 | } 43 | 44 | export type NoteMd1k = NoteMd1kNoId & WithId; 45 | 46 | export function isNoteMd1k(x: unknown): x is NoteMd1k { 47 | if (!isContextStream(get(x, "@context"))) return false; 48 | if (!isUri(get(x, "id"))) return false; 49 | if (get(x, "type") !== "Note") return false; 50 | const content = get(x, "content"); 51 | if (content == null) return false; 52 | if (typeof content !== "string") return false; 53 | if (new TextEncoder().encode(content).length > 1024) return false; 54 | if (get(x, "mediaType") !== "text/markdown") return false; 55 | if (!isUri(get(x, "attributedTo"))) return false; 56 | const inReplyTo = get(x, "inReplyTo"); 57 | if (inReplyTo != null && !isUri(inReplyTo)) return false; 58 | return true; 59 | } 60 | 61 | interface Tag30NoId { 62 | "@context": ContextStream; 63 | type: "Object"; 64 | name: string; 65 | } 66 | 67 | export async function newTag30(name: string): Promise { 68 | if (name.split("").length > 30) throw new Error("Name too long"); 69 | const document: Tag30NoId = { 70 | "@context": CONTEXT_STREAM, 71 | type: "Object", 72 | name, 73 | }; 74 | const cid = (await buildDocCid(document)).toString(); 75 | const id = `urn:cid:${cid}`; 76 | return { id, ...document }; 77 | } 78 | 79 | export async function verifyTag30(document: Tag30): Promise { 80 | const objectDocNoId = omit(document, ["id"]); 81 | const cid = (await buildDocCid(objectDocNoId)).toString(); 82 | if (`urn:cid:${cid}` !== document.id) return false; 83 | return true; 84 | } 85 | 86 | export type Tag30 = Tag30NoId & WithId; 87 | 88 | export function isTag30(x: unknown): x is Tag30 { 89 | if (!isContextStream(get(x, "@context"))) return false; 90 | if (!isUri(get(x, "id"))) return false; 91 | if (get(x, "type") !== "Object") return false; 92 | const name = get(x, "name"); 93 | if (name == null) return false; 94 | if (typeof name !== "string") return false; 95 | // @ts-ignore 96 | if (name.split("").length > 30) return false; 97 | return true; 98 | } 99 | -------------------------------------------------------------------------------- /types/jsonld-signatures/index.d.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (c) 2010-2022 Digital Bazaar, Inc. All rights reserved. 3 | */ 4 | 5 | const jsigs = { 6 | /** 7 | * Cryptographically signs the provided document by adding a `proof` section, 8 | * based on the provided suite and proof purpose. 9 | * 10 | * @param {object} document - The JSON-LD document to be signed. 11 | * 12 | * @param {object} options - Options hashmap. 13 | * @param {LinkedDataSignature} options.suite - The linked data signature 14 | * cryptographic suite, containing private key material, with which to sign 15 | * the document. 16 | * 17 | * @param {ProofPurpose} purpose - A proof purpose instance that will 18 | * match proofs to be verified and ensure they were created according to 19 | * the appropriate purpose. 20 | * 21 | * @param {function} documentLoader - A secure document loader (it is 22 | * recommended to use one that provides static known documents, instead of 23 | * fetching from the web) for returning contexts, controller documents, keys, 24 | * and other relevant URLs needed for the proof. 25 | * 26 | * Advanced optional parameters and overrides: 27 | * 28 | * @param {function} [options.expansionMap] - NOT SUPPORTED; do not use. 29 | * @param {boolean} [options.addSuiteContext=true] - Toggles the default 30 | * behavior of each signature suite enforcing the presence of its own 31 | * `@context` (if it is not present, it's added to the context list). 32 | * 33 | * @returns {Promise} Resolves with signed document. 34 | */ 35 | sign: async( 36 | document, 37 | ({ suite, purpose, documentLoader, expansionMap, addSuiteContext = true } = {}) 38 | ), 39 | 40 | /** 41 | * Verifies the linked data signature on the provided document. 42 | * 43 | * @param {object} document - The JSON-LD document with one or more proofs to be 44 | * verified. 45 | * 46 | * @param {object} options - The options to use. 47 | * @param {LinkedDataSignature|LinkedDataSignature[]} options.suite - 48 | * Acceptable signature suite instances for verifying the proof(s). 49 | * 50 | * @param {ProofPurpose} purpose - A proof purpose instance that will 51 | * match proofs to be verified and ensure they were created according to 52 | * the appropriate purpose. 53 | * 54 | * Advanced optional parameters and overrides: 55 | * 56 | * @param {function} [options.documentLoader] - A custom document loader, 57 | * `Promise documentLoader(url)`. 58 | * @param {function} [options.expansionMap] - NOT SUPPORTED; do not use. 59 | * 60 | * @return {Promise<{verified: boolean, results: Array, 61 | * error: VerificationError}>} 62 | * resolves with an object with a `verified` boolean property that is `true` 63 | * if at least one proof matching the given purpose and suite verifies and 64 | * `false` otherwise; a `results` property with an array of detailed results; 65 | * if `false` an `error` property will be present, with `error.errors` 66 | * containing all of the errors that occurred during the verification process. 67 | */ 68 | verify: async(document, ({ suite, purpose, documentLoader, expansionMap } = {})), 69 | 70 | // expose ProofPurpose classes to enable extensions 71 | purposes: { AssertionProofPurpose }, 72 | }; 73 | 74 | export default jsigs; 75 | -------------------------------------------------------------------------------- /src/ldcontexts/ed25519-2020.ts: -------------------------------------------------------------------------------- 1 | export const ed25519_2020 = { 2 | uri: "https://digitalbazaar.github.io/ed25519-signature-2020-context/contexts/ed25519-signature-2020-v1.jsonld", 3 | ctx: { 4 | "@context": { 5 | id: "@id", 6 | type: "@type", 7 | "@protected": true, 8 | proof: { 9 | "@id": "https://w3id.org/security#proof", 10 | "@type": "@id", 11 | "@container": "@graph", 12 | }, 13 | Ed25519VerificationKey2020: { 14 | "@id": "https://w3id.org/security#Ed25519VerificationKey2020", 15 | "@context": { 16 | "@protected": true, 17 | id: "@id", 18 | type: "@type", 19 | controller: { 20 | "@id": "https://w3id.org/security#controller", 21 | "@type": "@id", 22 | }, 23 | revoked: { 24 | "@id": "https://w3id.org/security#revoked", 25 | "@type": "http://www.w3.org/2001/XMLSchema#dateTime", 26 | }, 27 | publicKeyMultibase: { 28 | "@id": "https://w3id.org/security#publicKeyMultibase", 29 | "@type": "https://w3id.org/security#multibase", 30 | }, 31 | }, 32 | }, 33 | Ed25519Signature2020: { 34 | "@id": "https://w3id.org/security#Ed25519Signature2020", 35 | "@context": { 36 | "@protected": true, 37 | id: "@id", 38 | type: "@type", 39 | challenge: "https://w3id.org/security#challenge", 40 | created: { 41 | "@id": "http://purl.org/dc/terms/created", 42 | "@type": "http://www.w3.org/2001/XMLSchema#dateTime", 43 | }, 44 | domain: "https://w3id.org/security#domain", 45 | expires: { 46 | "@id": "https://w3id.org/security#expiration", 47 | "@type": "http://www.w3.org/2001/XMLSchema#dateTime", 48 | }, 49 | nonce: "https://w3id.org/security#nonce", 50 | proofPurpose: { 51 | "@id": "https://w3id.org/security#proofPurpose", 52 | "@type": "@vocab", 53 | "@context": { 54 | "@protected": true, 55 | id: "@id", 56 | type: "@type", 57 | assertionMethod: { 58 | "@id": "https://w3id.org/security#assertionMethod", 59 | "@type": "@id", 60 | "@container": "@set", 61 | }, 62 | authentication: { 63 | "@id": "https://w3id.org/security#authenticationMethod", 64 | "@type": "@id", 65 | "@container": "@set", 66 | }, 67 | capabilityInvocation: { 68 | "@id": "https://w3id.org/security#capabilityInvocationMethod", 69 | "@type": "@id", 70 | "@container": "@set", 71 | }, 72 | capabilityDelegation: { 73 | "@id": "https://w3id.org/security#capabilityDelegationMethod", 74 | "@type": "@id", 75 | "@container": "@set", 76 | }, 77 | keyAgreement: { 78 | "@id": "https://w3id.org/security#keyAgreementMethod", 79 | "@type": "@id", 80 | "@container": "@set", 81 | }, 82 | }, 83 | }, 84 | proofValue: { 85 | "@id": "https://w3id.org/security#proofValue", 86 | "@type": "https://w3id.org/security#multibase", 87 | }, 88 | verificationMethod: { 89 | "@id": "https://w3id.org/security#verificationMethod", 90 | "@type": "@id", 91 | }, 92 | }, 93 | }, 94 | }, 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /src/model/messages.ts: -------------------------------------------------------------------------------- 1 | import { WithProof, buildDocCid, isDateTime } from "../signatures.js"; 2 | import { DateTime, Key, sign, verify } from "../signatures.js"; 3 | import { getIsoDate } from "../utils.js"; 4 | import { didFromActorId } from "./actor.js"; 5 | import { 6 | CONTEXT_SIG_STREAM, 7 | ContextSigStream, 8 | Uri, 9 | WithId, 10 | isContextSigStream, 11 | isIterable, 12 | isUri, 13 | } from "./utils.js"; 14 | import { get, has, isEqual, omit } from "lodash-es"; 15 | import { CID } from "multiformats"; 16 | 17 | const MAX_MESSAGE_URIS = 256; 18 | 19 | function isUris(uris: unknown): uris is Uri[] { 20 | if (!isIterable(uris)) return false; 21 | let count = 0; 22 | for (const uri of uris) { 23 | count += 1; 24 | if (count > MAX_MESSAGE_URIS) return false; 25 | if (!isUri(uri)) return false; 26 | } 27 | return true; 28 | } 29 | 30 | interface MessageNoIdProof { 31 | "@context": ContextSigStream; 32 | type: string; 33 | actor: Uri; 34 | object: Uri[]; 35 | published: DateTime; 36 | to?: Uri[]; 37 | origin?: Uri[]; 38 | target?: Uri[]; 39 | } 40 | 41 | type MessageNoId = MessageNoIdProof & WithProof; 42 | export type Message = MessageNoId & WithId; 43 | 44 | export interface MessageOptions { 45 | to?: Uri[]; 46 | origin?: Uri[]; 47 | target?: Uri[]; 48 | } 49 | 50 | export async function newMessage( 51 | actorDid: Uri, 52 | objectsId: Uri[], 53 | type: string, 54 | published: DateTime | null, 55 | key: Key, 56 | options: MessageOptions = {} 57 | ): Promise { 58 | let actor = `${actorDid}/actor`; 59 | const message: MessageNoIdProof = { 60 | "@context": CONTEXT_SIG_STREAM, 61 | type, 62 | actor, 63 | object: objectsId, 64 | published: published != null ? published : getIsoDate(), 65 | ...options, 66 | }; 67 | const messageWithProof = await sign(message, key); 68 | const cid = (await buildDocCid(messageWithProof)).toString(); 69 | const id = `urn:cid:${cid}`; 70 | return { 71 | ...messageWithProof, 72 | id, 73 | }; 74 | } 75 | 76 | export async function verifyMessage(message: Message): Promise { 77 | const did = didFromActorId(message.actor); 78 | if (!did) return false; 79 | const id = message.id; 80 | if (!id.startsWith("urn:cid:")) return false; 81 | let cid: CID | undefined = undefined; 82 | try { 83 | cid = CID.parse(id.slice(8)); 84 | } catch {} 85 | if (cid == null) return false; 86 | const messageNoId = omit(message, ["id"]); 87 | const expectecCid = await buildDocCid(messageNoId); 88 | if (!isEqual(cid.multihash.bytes, expectecCid.multihash.bytes)) return false; 89 | if (!(await verify(messageNoId, did))) return false; 90 | return true; 91 | } 92 | 93 | export function isMessage(x: unknown): x is Message { 94 | if (!isContextSigStream(get(x, "@context"))) return false; 95 | if (!isUri(get(x, "id"))) return false; 96 | if (!has(x, "type")) return false; 97 | if (!isUri(get(x, "actor"))) return false; 98 | if (!isDateTime(get(x, "published"))) return false; 99 | if (!isUris(get(x, "object"))) return false; 100 | const to = get(x, "to"); 101 | if (to != null && !isUris(to)) return false; 102 | const cc = get(x, "cc"); 103 | if (cc != null && !isUris(cc)) return false; 104 | const audience = get(x, "cc"); 105 | if (audience != null && !isUris(audience)) return false; 106 | return true; 107 | } 108 | 109 | export function getAudiences(message: Message): Uri[] { 110 | let audiences: Set = new Set(); 111 | if (message.to == null) return []; 112 | message.to.filter((x) => isUri(x)).forEach((x) => audiences.add(x)); 113 | return [...audiences.values()]; 114 | } 115 | -------------------------------------------------------------------------------- /types/@digitalbazaar__did-method-key/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@digitalbazaar/did-method-key" { 2 | /*! 3 | * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. 4 | */ 5 | export class DidKeyDriver { 6 | /** 7 | * @param {object} options - Options hashmap. 8 | * @param {object} [options.verificationSuite=Ed25519VerificationKey2020] - 9 | * Key suite for the signature verification key suite to use. 10 | */ 11 | constructor({ verificationSuite = Ed25519VerificationKey2020 } = {}); 12 | 13 | /** 14 | * Generates a new `did:key` method DID Document (optionally, from a 15 | * deterministic seed value). 16 | * 17 | * @param {object} options - Options hashmap. 18 | * @param {Uint8Array} [options.seed] - A 32-byte array seed for a 19 | * deterministic key. 20 | * 21 | * @returns {Promise<{didDocument: object, keyPairs: Map, 22 | * methodFor: Function}>} Resolves with the generated DID Document, along 23 | * with the corresponding key pairs used to generate it (for storage in a 24 | * KMS). 25 | */ 26 | async generate({ seed } = {}); 27 | 28 | /** 29 | * Returns the public key (verification method) object for a given DID 30 | * Document and purpose. Useful in conjunction with a `.get()` call. 31 | * 32 | * @example 33 | * const didDocument = await didKeyDriver.get({did}); 34 | * const authKeyData = didDriver.publicMethodFor({ 35 | * didDocument, purpose: 'authentication' 36 | * }); 37 | * // You can then create a suite instance object to verify signatures etc. 38 | * const authPublicKey = await cryptoLd.from(authKeyData); 39 | * const {verify} = authPublicKey.verifier(); 40 | * 41 | * @param {object} options - Options hashmap. 42 | * @param {object} options.didDocument - DID Document (retrieved via a 43 | * `.get()` or from some other source). 44 | * @param {string} options.purpose - Verification method purpose, such as 45 | * 'authentication', 'assertionMethod', 'keyAgreement' and so on. 46 | * 47 | * @returns {object} Returns the public key object (obtained from the DID 48 | * Document), without a `@context`. 49 | */ 50 | publicMethodFor({ didDocument, purpose } = {}); 51 | 52 | /** 53 | * Returns a `did:key` method DID Document for a given DID, or a key document 54 | * for a given DID URL (key id). 55 | * Either a `did` or `url` param is required. 56 | * 57 | * @example 58 | * await resolver.get({did}); // -> did document 59 | * await resolver.get({url: keyId}); // -> public key node 60 | * 61 | * @param {object} options - Options hashmap. 62 | * @param {string} [options.did] - DID URL or a key id (either an ed25519 key 63 | * or an x25519 key-agreement key id). 64 | * @param {string} [options.url] - Alias for the `did` url param, supported 65 | * for better readability of invoking code. 66 | * 67 | * @returns {Promise} Resolves to a DID Document or a 68 | * public key node with context. 69 | */ 70 | async get({ did, url } = {}); 71 | 72 | /** 73 | * Converts a public key object to a `did:key` method DID Document. 74 | * Note that unlike `generate()`, a `keyPairs` map is not returned. Use 75 | * `publicMethodFor()` to fetch keys for particular proof purposes. 76 | * 77 | * @param {object} options - Options hashmap. 78 | * @typedef LDKeyPair 79 | * @param {LDKeyPair|object} options.publicKeyDescription - Public key object 80 | * used to generate the DID document (either an LDKeyPair instance 81 | * containing public key material, or a "key description" plain object 82 | * (such as that generated from a KMS)). 83 | * 84 | * @returns {Promise} Resolves with the generated DID Document. 85 | */ 86 | async publicKeyToDidDoc({ publicKeyDescription } = {}); 87 | 88 | /** 89 | * Computes and returns the id of a given key pair. Used by `did-io` drivers. 90 | * 91 | * @param {object} options - Options hashmap. 92 | * @param {LDKeyPair} options.keyPair - The key pair used when computing the 93 | * identifier. 94 | * 95 | * @returns {string} Returns the key's id. 96 | */ 97 | async computeId({ keyPair }) { 98 | return `did:key:${keyPair.fingerprint()}#${keyPair.fingerprint()}`; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /types/@digitalbazaar__ed25519-verification-key-2020/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@digitalbazaar/ed25519-verification-key-2020" { 2 | /*! 3 | * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. 4 | */ 5 | export class Ed25519VerificationKey2020 extends LDKeyPair { 6 | suite: string; 7 | 8 | /** 9 | * An implementation of the Ed25519VerificationKey2020 spec, for use with 10 | * Linked Data Proofs. 11 | * 12 | * @see https://w3c-ccg.github.io/lds-ed25519-2020/#ed25519verificationkey2020 13 | * @see https://github.com/digitalbazaar/jsonld-signatures 14 | * 15 | * @param {object} options - Options hashmap. 16 | * @param {string} options.controller - Controller DID or document url. 17 | * @param {string} [options.id] - The key ID. If not provided, will be 18 | * composed of controller and key fingerprint as hash fragment. 19 | * @param {string} options.publicKeyMultibase - Multibase encoded public key 20 | * with a multicodec ed25519-pub varint header [0xed, 0x01]. 21 | * @param {string} [options.privateKeyMultibase] - Multibase private key 22 | * with a multicodec ed25519-priv varint header [0x80, 0x26]. 23 | * @param {string} [options.revoked] - Timestamp of when the key has been 24 | * revoked, in RFC3339 format. If not present, the key itself is considered 25 | * not revoked. Note that this mechanism is slightly different than DID 26 | * Document key revocation, where a DID controller can revoke a key from 27 | * that DID by removing it from the DID Document. 28 | */ 29 | constructor(options = {}); 30 | 31 | /** 32 | * Creates an Ed25519 Key Pair from an existing serialized key pair. 33 | * 34 | * @param {object} options - Key pair options (see constructor). 35 | * @example 36 | * > const keyPair = await Ed25519VerificationKey2020.from({ 37 | * controller: 'did:ex:1234', 38 | * type: 'Ed25519VerificationKey2020', 39 | * publicKeyMultibase, 40 | * privateKeyMultibase 41 | * }); 42 | * 43 | * @returns {Promise} An Ed25519 Key Pair. 44 | */ 45 | static async from(options); 46 | 47 | /** 48 | * Instance creation method for backwards compatibility with the 49 | * `Ed25519VerificationKey2018` key suite. 50 | * 51 | * @see https://github.com/digitalbazaar/ed25519-verification-key-2018 52 | * @typedef {object} Ed25519VerificationKey2018 53 | * @param {Ed25519VerificationKey2018} keyPair - Ed25519 2018 suite key pair. 54 | * 55 | * @returns {Ed25519VerificationKey2020} - 2020 suite instance. 56 | */ 57 | static fromEd25519VerificationKey2018({ keyPair } = {}); 58 | 59 | /** 60 | * Creates a key pair instance (public key only) from a JsonWebKey2020 61 | * object. 62 | * 63 | * @see https://w3c-ccg.github.io/lds-jws2020/#json-web-key-2020 64 | * 65 | * @param {object} options - Options hashmap. 66 | * @param {string} options.id - Key id. 67 | * @param {string} options.type - Key suite type. 68 | * @param {string} options.controller - Key controller. 69 | * @param {object} options.publicKeyJwk - JWK object. 70 | * 71 | * @returns {Promise} Resolves with key pair. 72 | */ 73 | static fromJsonWebKey2020({ id, type, controller, publicKeyJwk } = {}); 74 | 75 | /** 76 | * Generates a KeyPair with an optional deterministic seed. 77 | * 78 | * @param {object} [options={}] - Options hashmap. 79 | * @param {Uint8Array} [options.seed] - A 32-byte array seed for a 80 | * deterministic key. 81 | * 82 | * @returns {Promise} Resolves with generated 83 | * public/private key pair. 84 | */ 85 | static async generate({ seed, ...keyPairOptions } = {}); 86 | 87 | /** 88 | * Creates an instance of Ed25519VerificationKey2020 from a key fingerprint. 89 | * 90 | * @param {object} options - Options hashmap. 91 | * @param {string} options.fingerprint - Multibase encoded key fingerprint. 92 | * 93 | * @returns {Ed25519VerificationKey2020} Returns key pair instance (with 94 | * public key only). 95 | */ 96 | static fromFingerprint({ fingerprint } = {}); 97 | 98 | /** 99 | * Generates and returns a multiformats encoded 100 | * ed25519 public key fingerprint (for use with cryptonyms, for example). 101 | * 102 | * @see https://github.com/multiformats/multicodec 103 | * 104 | * @returns {string} The fingerprint. 105 | */ 106 | fingerprint(); 107 | 108 | /** 109 | * Exports the serialized representation of the KeyPair 110 | * and other information that JSON-LD Signatures can use to form a proof. 111 | * 112 | * @param {object} [options={}] - Options hashmap. 113 | * @param {boolean} [options.publicKey] - Export public key material? 114 | * @param {boolean} [options.privateKey] - Export private key material? 115 | * @param {boolean} [options.includeContext] - Include JSON-LD context? 116 | * 117 | * @returns {object} A plain js object that's ready for serialization 118 | * (to JSON, etc), for use in DIDs, Linked Data Proofs, etc. 119 | */ 120 | export({ publicKey = false, privateKey = false, includeContext = false } = {}); 121 | 122 | /** 123 | * Returns the JWK representation of this key pair. 124 | * 125 | * @see https://datatracker.ietf.org/doc/html/rfc8037 126 | * 127 | * @param {object} [options={}] - Options hashmap. 128 | * @param {boolean} [options.publicKey] - Include public key? 129 | * @param {boolean} [options.privateKey] - Include private key? 130 | * 131 | * @returns {{kty: string, crv: string, x: string, d: string}} JWK 132 | * representation. 133 | */ 134 | toJwk({ publicKey = true, privateKey = false } = {}); 135 | 136 | /** 137 | * @see https://datatracker.ietf.org/doc/html/rfc8037#appendix-A.3 138 | * 139 | * @returns {Promise} JWK Thumbprint. 140 | */ 141 | async jwkThumbprint(); 142 | 143 | /** 144 | * Returns the JsonWebKey2020 representation of this key pair. 145 | * 146 | * @see https://w3c-ccg.github.io/lds-jws2020/#json-web-key-2020 147 | * 148 | * @returns {Promise} JsonWebKey2020 representation. 149 | */ 150 | async toJsonWebKey2020(); 151 | 152 | /** 153 | * Tests whether the fingerprint was generated from a given key pair. 154 | * 155 | * @example 156 | * > edKeyPair.verifyFingerprint({fingerprint: 'z6Mk2S2Q...6MkaFJewa'}); 157 | * {valid: true}; 158 | * 159 | * @param {object} options - Options hashmap. 160 | * @param {string} options.fingerprint - A public key fingerprint. 161 | * 162 | * @returns {{valid: boolean, error: *}} Result of verification. 163 | */ 164 | verifyFingerprint({ fingerprint } = {}); 165 | 166 | signer(); 167 | 168 | verifier(); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/servers.ts: -------------------------------------------------------------------------------- 1 | import * as Model from "./model/index.js"; 2 | import { isWithId } from "./model/utils.js"; 3 | import type { ServerInfo } from "./storage.js"; 4 | import { get } from "lodash-es"; 5 | 6 | export interface Server { 7 | url: string; 8 | did: string; 9 | knownIds: Set; 10 | } 11 | 12 | export interface PageOut { 13 | items: unknown[]; 14 | nextStartIdx?: number; 15 | } 16 | 17 | export function newServer(info: ServerInfo): Server { 18 | const knownIds: Set = new Set(); 19 | return { ...info, knownIds }; 20 | } 21 | 22 | async function postMessage( 23 | message: Model.Message, 24 | did: string, 25 | serverUrl: string 26 | ): Promise { 27 | serverUrl = serverUrl.replace(/\/$/, ""); 28 | const url = new URL(`${serverUrl}/${did}/actor/outbox`); 29 | const request = new Request(url, { 30 | method: "POST", 31 | body: JSON.stringify(message), 32 | headers: { "Content-Type": "application/json" }, 33 | }); 34 | return await fetch(request); 35 | } 36 | 37 | async function postDocument(document: Model.WithId, serverUrl: string): Promise { 38 | serverUrl = serverUrl.replace(/\/$/, ""); 39 | const url = new URL(`${serverUrl}/${document.id}`); 40 | const request = new Request(url, { 41 | method: "POST", 42 | body: JSON.stringify(document), 43 | headers: { "Content-Type": "application/json" }, 44 | }); 45 | return await fetch(request); 46 | } 47 | 48 | async function getPaginated( 49 | uri: string, 50 | serverUrl: string, 51 | startIdx?: number, 52 | pageSize?: number 53 | ): Promise { 54 | serverUrl = serverUrl.replace(/\/$/, ""); 55 | const url = new URL(`${serverUrl}/${uri}`); 56 | if (startIdx) url.searchParams.set("startIdx", startIdx.toString()); 57 | if (pageSize) url.searchParams.set("pageSize", pageSize.toString()); 58 | const request = new Request(url, { 59 | method: "GET", 60 | }); 61 | return await fetch(request); 62 | } 63 | 64 | async function getDocument(id: string, serverUrl: string): Promise { 65 | serverUrl = serverUrl.replace(/\/$/, ""); 66 | const url = new URL(`${serverUrl}/${id}`); 67 | const request = new Request(url, { 68 | method: "GET", 69 | }); 70 | return await fetch(request); 71 | } 72 | 73 | async function getCreateMessageForDocument( 74 | id: string, 75 | actorId: string, 76 | serverUrl: string 77 | ): Promise { 78 | serverUrl = serverUrl.replace(/\/$/, ""); 79 | const url = new URL(`${serverUrl}/${id}/createdBy/${actorId}`); 80 | const request = new Request(url, { 81 | method: "GET", 82 | }); 83 | return await fetch(request); 84 | } 85 | 86 | export class Servers { 87 | constructor( 88 | readonly urlsServer: Map, 89 | readonly documentCache: Map 90 | ) {} 91 | 92 | static fromInfos(infos: ServerInfo[]): Servers { 93 | return new Servers(new Map(infos.map((x) => [x.url, newServer(x)])), new Map()); 94 | } 95 | 96 | async postMessage(message: Model.Message, did: string) { 97 | let anySuccess = false; 98 | // messages are isomorphic to ID, can cache 99 | this.documentCache.set(message.id, message); 100 | // keep the servers in sync by sharing all processed messages 101 | for (const { url, knownIds } of this.urlsServer.values()) { 102 | const response = await postMessage(message, did, url); 103 | if (!response.ok) { 104 | console.info("message failed to post to %s: %s", url, await response.text()); 105 | continue; 106 | } 107 | knownIds.add(message.id); 108 | anySuccess = true; 109 | } 110 | if (!anySuccess) throw Error("message failed to post to any server"); 111 | } 112 | 113 | async postDocument(document: Model.WithId) { 114 | let anySuccess = false; 115 | if (document.id.startsWith("urn:cid:")) 116 | // object ID is a CID, it is isomorphic to its content, can cache 117 | this.documentCache.set(document.id, document); 118 | // keep the servers in sync by sharing all processed messages 119 | for (const { url, knownIds } of this.urlsServer.values()) { 120 | const response = await postDocument(document, url); 121 | if (!response.ok) { 122 | console.info("document failed to post to %s: %s", url, await response.text()); 123 | continue; 124 | } 125 | knownIds.add(document.id); 126 | anySuccess = true; 127 | } 128 | if (!anySuccess) throw Error("document failed to post to any server"); 129 | } 130 | 131 | sortServersByKnownId(id: string): Server[] { 132 | // want to iterate starting with most likely to have doc 133 | const servers = [...this.urlsServer.values()]; 134 | servers.sort((a, b) => { 135 | // a is known, not b: sort a before b 136 | if (a.knownIds.has(id) && !b.knownIds.has(id)) return -1; 137 | // b is known, not a: sort b before a 138 | if (!a.knownIds.has(id) && b.knownIds.has(id)) return +1; 139 | return 0; 140 | }); 141 | return servers; 142 | } 143 | 144 | async getDocument(id: string): Promise { 145 | // first try the local cache 146 | const local = this.documentCache.get(id); 147 | if (local != null) return local; 148 | 149 | const servers = this.sortServersByKnownId(id); 150 | 151 | for (const server of servers) { 152 | let response: Response; 153 | try { 154 | response = await getDocument(id, server.url); 155 | } catch { 156 | continue; 157 | } 158 | if (!response.ok) continue; 159 | const document: unknown = await response.json(); 160 | // TODO: validate ID 161 | if (!isWithId(document)) continue; 162 | server.knownIds.add(document.id); 163 | return document; 164 | } 165 | } 166 | 167 | async getCreateMessageForDocument( 168 | id: string, 169 | actorId: string 170 | ): Promise { 171 | const servers = this.sortServersByKnownId(id); 172 | 173 | for (const server of servers) { 174 | let response: Response; 175 | try { 176 | response = await getCreateMessageForDocument(id, actorId, server.url); 177 | } catch { 178 | continue; 179 | } 180 | if (!response.ok) continue; 181 | const message: unknown = await response.json(); 182 | if (!Model.isMessage(message)) continue; 183 | if (!(await Model.verifyMessage(message))) continue; 184 | return message; 185 | } 186 | } 187 | 188 | static getNextStartIdxFromPage(page: any): number | undefined { 189 | const next = get(page, "next"); 190 | if (next == null) return; 191 | const startIdx = new URL(next).searchParams.get("startIdx"); 192 | if (startIdx == null) return; 193 | return +startIdx; 194 | } 195 | 196 | async getPaginated( 197 | uri: string, 198 | serverUrl: string, 199 | startIdx?: number, 200 | pageSize?: number 201 | ): Promise { 202 | const server = this.urlsServer.get(serverUrl); 203 | if (!server) throw Error("server URL is not known"); 204 | const response = await getPaginated(uri, serverUrl, startIdx, pageSize); 205 | if (!response.ok) throw Error("unable to get paginated resource"); 206 | const page: unknown = await response.json(); 207 | const nextStartIdx = Servers.getNextStartIdxFromPage(page); 208 | const items: unknown = get(page, "items"); 209 | if (!Array.isArray(items)) throw Error("page is incorrectly formatted"); 210 | return { items, nextStartIdx }; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /test/servers.spec.ts: -------------------------------------------------------------------------------- 1 | import * as DidKey from "../src/didkey.js"; 2 | import * as Model from "../src/model/index.js"; 3 | import { Servers } from "../src/servers.js"; 4 | import * as assert from "assert"; 5 | 6 | describe("servers", () => { 7 | const originalFetch = global.fetch; 8 | 9 | function resetFetch() { 10 | global.fetch = originalFetch; 11 | } 12 | 13 | it("posts messages", async () => { 14 | resetFetch(); 15 | const key = await DidKey.newKey(); 16 | const did = DidKey.didFromKey(key); 17 | const infos = [ 18 | { url: "http://a.example", did: "did:example:a" }, 19 | { url: "http://b.example", did: "did:example:b" }, 20 | ]; 21 | const requestedUrls: string[] = []; 22 | global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 23 | const request = input as Request; 24 | if (request.method != "POST") return new Response(null, { status: 500 }); 25 | requestedUrls.push(request.url.toString()); 26 | return new Response(); 27 | }; 28 | const servers = await Servers.fromInfos(infos); 29 | const message = await Model.newMessage(did, ["urn:cid:a"], "Create", null, key); 30 | await servers.postMessage(message, did); 31 | assert.deepEqual(requestedUrls, [ 32 | `http://a.example/${did}/actor/outbox`, 33 | `http://b.example/${did}/actor/outbox`, 34 | ]); 35 | }); 36 | 37 | it("posts objects", async () => { 38 | resetFetch(); 39 | const infos = [ 40 | { url: "http://a.example", did: "did:example:a" }, 41 | { url: "http://b.example", did: "did:example:b" }, 42 | ]; 43 | const requestedUrls: string[] = []; 44 | global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 45 | const request = input as Request; 46 | if (request.method != "POST") return new Response(null, { status: 500 }); 47 | requestedUrls.push(request.url.toString()); 48 | return new Response(); 49 | }; 50 | const servers = await Servers.fromInfos(infos); 51 | const objectDoc = await Model.newNoteMd1k("Note", "did:example:a"); 52 | await servers.postDocument(objectDoc); 53 | assert.deepEqual(requestedUrls, [ 54 | `http://a.example/${objectDoc.id}`, 55 | `http://b.example/${objectDoc.id}`, 56 | ]); 57 | }); 58 | 59 | it("gets an object", async () => { 60 | resetFetch(); 61 | const infos = [ 62 | { url: "http://a.example", did: "did:example:a" }, 63 | { url: "http://b.example", did: "did:example:b" }, 64 | ]; 65 | const objectDoc = await Model.newNoteMd1k("Note", "did:example:a"); 66 | global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 67 | const request = input as Request; 68 | if (request.method === "GET" && request.url.toString() === `http://a.example/${objectDoc.id}`) 69 | return new Response(JSON.stringify(objectDoc)); 70 | return new Response(null, { status: 500 }); 71 | }; 72 | const servers = await Servers.fromInfos(infos); 73 | 74 | const returnedBody = await servers.getDocument(objectDoc.id); 75 | assert.deepEqual(returnedBody, JSON.parse(JSON.stringify(objectDoc))); 76 | }); 77 | 78 | it("gets create message for an object", async () => { 79 | resetFetch(); 80 | const infos = [ 81 | { url: "http://a.example", did: "did:example:a" }, 82 | { url: "http://b.example", did: "did:example:b" }, 83 | ]; 84 | const objectId = "urn:cid:1"; 85 | const jwk = await DidKey.newKey(); 86 | const did = DidKey.didFromKey(jwk); 87 | const actorId = `${did}/actor`; 88 | const message = await Model.newMessage(actorId, [objectId], "Create", null, jwk); 89 | global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 90 | const request = input as Request; 91 | if ( 92 | request.method === "GET" && 93 | request.url.toString() === `http://a.example/${objectId}/createdBy/${actorId}` 94 | ) 95 | return new Response(JSON.stringify(message)); 96 | return new Response(null, { status: 500 }); 97 | }; 98 | const servers = await Servers.fromInfos(infos); 99 | 100 | const returned = await servers.getCreateMessageForDocument(objectId, actorId); 101 | assert.deepEqual(returned, JSON.parse(JSON.stringify(message))); 102 | }); 103 | 104 | it("get object from server that already had it", async () => { 105 | resetFetch(); 106 | const infos = [ 107 | { url: "http://a.example", did: "did:example:a" }, 108 | { url: "http://b.example", did: "did:example:b" }, 109 | ]; 110 | let requestedUrls: string[] = []; 111 | const objectDoc = await Model.newNoteMd1k("Note", "did:example:a"); 112 | global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 113 | const request = input as Request; 114 | requestedUrls.push(request.url.toString()); 115 | if (request.method === "GET" && request.url.toString() === `http://b.example/${objectDoc.id}`) 116 | return new Response(JSON.stringify(objectDoc)); 117 | if (request.method === "GET" && request.url.toString() === `http://a.example/${objectDoc.id}`) 118 | return new Response(null, { status: 404 }); 119 | return new Response(null, { status: 500 }); 120 | }; 121 | const servers = await Servers.fromInfos(infos); 122 | // tries both URLs before finding the object 123 | await servers.getDocument(objectDoc.id); 124 | assert.deepEqual(requestedUrls, [ 125 | `http://a.example/${objectDoc.id}`, 126 | `http://b.example/${objectDoc.id}`, 127 | ]); 128 | // directly asks b since it knows it has the object 129 | requestedUrls = []; 130 | await servers.getDocument(objectDoc.id); 131 | assert.deepEqual(requestedUrls, [`http://b.example/${objectDoc.id}`]); 132 | }); 133 | 134 | it("doesnt get invalid actor", async () => { 135 | resetFetch(); 136 | }); 137 | 138 | it("gets collection", async () => { 139 | resetFetch(); 140 | 141 | const infos = [ 142 | { url: "http://a.example", did: "did:example:a" }, 143 | { url: "http://b.example", did: "did:example:b" }, 144 | ]; 145 | const servers = await Servers.fromInfos(infos); 146 | 147 | global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 148 | const request = input as Request; 149 | const notFound = new Response(null, { status: 404 }); 150 | if (request.method !== "GET") return notFound; 151 | const url = new URL(request.url); 152 | if (url.origin !== "http://a.example") return notFound; 153 | if (url.pathname !== "/resource-uri") return notFound; 154 | 155 | let items = []; 156 | const startIdx = url.searchParams.get("startIdx"); 157 | if (startIdx == null || startIdx === "3") items = ["item3", "item2", "item1"]; 158 | else if (startIdx === "2") items = ["item2", "item1"]; 159 | else if (startIdx === "1") items = ["item1"]; 160 | else return notFound; 161 | 162 | const pageSize = url.searchParams.get("pageSize"); 163 | if (pageSize != null) items = items.slice(0, +pageSize); 164 | 165 | let nextStartIdx = undefined; 166 | if (items[items.length - 1] == "item3") nextStartIdx = 2; 167 | if (items[items.length - 1] == "item2") nextStartIdx = 1; 168 | 169 | return new Response(JSON.stringify({ items, nextStartIdx })); 170 | }; 171 | 172 | { 173 | const { items, nextStartIdx } = await servers.getPaginated( 174 | "resource-uri", 175 | "http://a.example" 176 | ); 177 | assert.ok(!nextStartIdx); 178 | assert.deepEqual(items, ["item3", "item2", "item1"]); 179 | } 180 | 181 | { 182 | const { items, nextStartIdx } = await servers.getPaginated( 183 | "resource-uri", 184 | "http://a.example", 185 | 3 186 | ); 187 | assert.ok(!nextStartIdx); 188 | assert.deepEqual(items, ["item3", "item2", "item1"]); 189 | } 190 | 191 | { 192 | const { items, nextStartIdx } = await servers.getPaginated( 193 | "resource-uri", 194 | "http://a.example", 195 | undefined, 196 | 2 197 | ); 198 | assert.ok(!nextStartIdx); 199 | assert.deepEqual(items, ["item3", "item2"]); 200 | } 201 | 202 | { 203 | const { items, nextStartIdx } = await servers.getPaginated( 204 | "resource-uri", 205 | "http://a.example", 206 | 2 207 | ); 208 | assert.ok(!nextStartIdx); 209 | assert.deepEqual(items, ["item2", "item1"]); 210 | } 211 | 212 | { 213 | const { items, nextStartIdx } = await servers.getPaginated( 214 | "resource-uri", 215 | "http://a.example", 216 | 2, 217 | 1 218 | ); 219 | assert.ok(!nextStartIdx); 220 | assert.deepEqual(items, ["item2"]); 221 | } 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /test/storage.spec.ts: -------------------------------------------------------------------------------- 1 | import { DidKey, Model } from "../src/index.js"; 2 | import * as Storage from "../src/storage.js"; 3 | import * as assert from "assert"; 4 | import "fake-indexeddb/auto"; 5 | 6 | // @ts-ignore 7 | if (!global.window) global.window = { crypto: globalThis.crypto }; 8 | 9 | describe("storage", () => { 10 | describe("db device", () => { 11 | it("puts and gets id salt", async () => { 12 | const db = await Storage.DbDevice.new(); 13 | await db.clear(); 14 | const salt = await db.idSalt.getPut("did:example:a"); 15 | assert.deepEqual(await db.idSalt.getPut("did:example:a"), salt); 16 | assert.notDeepEqual(await db.idSalt.getPut("did:example:b"), salt); 17 | }); 18 | 19 | it("puts and gets key pair", async () => { 20 | const db = await Storage.DbDevice.new(); 21 | await db.clear(); 22 | 23 | const key1 = await DidKey.newKey(); 24 | const did1 = DidKey.didFromKey(key1); 25 | const salt1 = await db.idSalt.getPut(did1); 26 | const cryptoKey1 = await Storage.cryptoKeyFromPassword("abc", salt1); 27 | await db.keyPair.put(key1, cryptoKey1); 28 | 29 | const key2 = await DidKey.newKey(); 30 | const did2 = DidKey.didFromKey(key2); 31 | const salt2 = await db.idSalt.getPut(did1); 32 | const cryptoKey2 = await Storage.cryptoKeyFromPassword("abcd", salt2); 33 | await db.keyPair.put(key2, cryptoKey2); 34 | 35 | const back1 = await db.keyPair.get(did1, cryptoKey1); 36 | assert.ok(back1); 37 | assert.deepEqual(back1.fingerprint(), key1.fingerprint()); 38 | 39 | assert.deepEqual(new Set([...(await db.keyPair.getDids())]), new Set([did1, did2])); 40 | }); 41 | 42 | it("puts and gets name", async () => { 43 | const db = await Storage.DbDevice.new(); 44 | await db.clear(); 45 | await db.idName.put({ id: "did:example:a", name: "name 1", timestamp: 0 }); 46 | assert.equal((await db.idName.get("did:example:a"))?.name, "name 1"); 47 | await db.idName.put({ id: "did:example:b", name: "name 2", timestamp: 0 }); 48 | assert.equal((await db.idName.get("did:example:b"))?.name, "name 2"); 49 | }); 50 | }); 51 | 52 | describe("db peer", () => { 53 | it("puts and gets servers", async () => { 54 | const db = await Storage.DbPeer.new(); 55 | await db.clear(); 56 | await db.server.update({ 57 | info: { url: "https://a.example.com", did: "did:example:a" }, 58 | lastListenTimestamp: 0, 59 | }); 60 | // update 61 | await db.server.update({ 62 | info: { url: "https://a.example.com", did: "did:example:a" }, 63 | lastListenTimestamp: 0, 64 | }); 65 | await db.server.update({ 66 | info: { url: "https://a.example.com", did: "did:example:a" }, 67 | lastListenTimestamp: 1, 68 | }); 69 | await db.server.update({ 70 | info: { url: "https://b.example.com", did: "did:example:a" }, 71 | lastListenTimestamp: 0, 72 | }); 73 | await db.server.update({ 74 | info: { url: "https://c.example.com", did: "did:example:c" }, 75 | lastListenTimestamp: 2, 76 | }); 77 | assert.deepEqual(await db.server.getByLastListen(2), [ 78 | { url: "https://c.example.com", did: "did:example:c" }, 79 | { url: "https://a.example.com", did: "did:example:a" }, 80 | ]); 81 | }); 82 | 83 | it("puts gets deletes follow", async () => { 84 | const db = await Storage.DbPeer.new(); 85 | await db.clear(); 86 | await db.follow.put("did:example:a"); 87 | await db.follow.put("did:example:a"); 88 | await db.follow.put("did:example:b"); 89 | await db.follow.put("did:example:c"); 90 | await db.follow.delete("did:example:b"); 91 | assert.deepEqual(await db.follow.getAll(), ["did:example:a", "did:example:c"]); 92 | }); 93 | 94 | it("puts gets deletes message ids", async () => { 95 | const db = await Storage.DbPeer.new(); 96 | await db.clear(); 97 | await db.message.put("id:a"); 98 | await db.message.put("id:b"); 99 | await db.message.put("id:c"); 100 | let out1 = await db.message.getPage(undefined, 2); 101 | assert.deepEqual(out1.ids, ["id:c", "id:b"]); 102 | let out2 = await db.message.getPage(out1.nextStartIdx, 2); 103 | assert.deepEqual(out2.ids, ["id:a"]); 104 | let out3 = await db.message.getPage(out2.nextStartIdx, 2); 105 | assert.deepEqual(out3.ids, []); 106 | assert.equal(out3.nextStartIdx, null); 107 | await db.message.delete("id:c"); 108 | await db.message.delete("id:d"); 109 | let out4 = await db.message.getPage(undefined, 2); 110 | assert.deepEqual(out4.ids, ["id:b", "id:a"]); 111 | }); 112 | 113 | it("puts gets deletes object doc", async () => { 114 | const db = await Storage.DbPeer.new(); 115 | await db.clear(); 116 | const doc1 = await Model.newNoteMd1k("abc", "did:example:a"); 117 | const doc2 = await Model.newNoteMd1k("abcd", "did:example:a"); 118 | await db.document.put(doc1); 119 | await db.document.put(doc2); 120 | await db.document.put(doc2); 121 | assert.deepEqual(await db.document.get(doc1.id), doc1); 122 | assert.deepEqual(await db.document.get(doc2.id), doc2); 123 | await db.document.delete(doc1.id); 124 | assert.ok(!(await db.document.get(doc1.id))); 125 | }); 126 | 127 | it("puts gets has deletes message body", async () => { 128 | const db = await Storage.DbPeer.new(); 129 | await db.clear(); 130 | await db.messageDocument.put("id:m1", "id:b1"); 131 | await db.messageDocument.put("id:m2", "id:b1"); 132 | await db.messageDocument.put("id:m3", "id:b2"); 133 | await db.messageDocument.put("id:m3", "id:b3"); 134 | assert.ok(await db.messageDocument.hasMessageWithDocument("id:b1")); 135 | assert.ok(await db.messageDocument.hasMessageWithDocument("id:b2")); 136 | assert.ok(await db.messageDocument.hasMessageWithDocument("id:b3")); 137 | assert.ok(!(await db.messageDocument.hasMessageWithDocument("id:b4"))); 138 | assert.deepEqual(await db.messageDocument.getDocumentsForMessage("id:m3"), [ 139 | "id:b2", 140 | "id:b3", 141 | ]); 142 | await db.messageDocument.delete("id:m1", "id:b1"); 143 | assert.ok(await db.messageDocument.hasMessageWithDocument("id:b1")); 144 | await db.messageDocument.delete("id:m2", "id:b1"); 145 | assert.ok(!(await db.messageDocument.hasMessageWithDocument("id:b1"))); 146 | await db.messageDocument.deleteForMessage("id:m3"); 147 | assert.ok(!(await db.messageDocument.hasMessageWithDocument("id:b2"))); 148 | assert.ok(!(await db.messageDocument.hasMessageWithDocument("id:b3"))); 149 | }); 150 | 151 | it("puts and gets view message", async () => { 152 | const db = await Storage.DbPeer.new(); 153 | await db.clear(); 154 | const key = await DidKey.newKey(); 155 | const did = DidKey.didFromKey(key); 156 | const view1 = await Model.newMessage(did, ["id:a"], "View", null, key); 157 | const view2 = await Model.newMessage(did, ["id:b"], "View", null, key); 158 | await db.viewMessage.put(view1); 159 | await db.viewMessage.put(view2); 160 | assert.deepEqual(await db.viewMessage.get("id:a"), view1); 161 | assert.deepEqual(await db.viewMessage.get("id:b"), view2); 162 | // overrides 163 | const view3 = await Model.newMessage(did, ["id:a"], "View", null, key); 164 | await db.viewMessage.put(view3); 165 | assert.deepEqual(await db.viewMessage.get("id:a"), view3); 166 | }); 167 | 168 | it("puts has deleted ID", async () => { 169 | const db = await Storage.DbPeer.new(); 170 | await db.clear(); 171 | db.deleted.put("id:a"); 172 | db.deleted.put("id:b"); 173 | assert.ok(await db.deleted.hasId("id:a")); 174 | assert.ok(await db.deleted.hasId("id:b")); 175 | assert.ok(!(await db.deleted.hasId("id:c"))); 176 | }); 177 | 178 | it("puts, updates, gets name", async () => { 179 | const db = await Storage.DbPeer.new(); 180 | await db.clear(); 181 | 182 | // puts and overwrites regardless of timestamp 183 | await db.idName.put({ id: "did:example:a", name: "name 1", timestamp: 10 }); 184 | assert.equal((await db.idName.get("did:example:a"))?.name, "name 1"); 185 | await db.idName.put({ id: "did:example:a", name: "name 1a", timestamp: 9 }); 186 | assert.equal((await db.idName.get("did:example:a"))?.name, "name 1a"); 187 | 188 | // puts and updates if newer 189 | await db.idName.putIfNewer({ id: "did:example:b", name: "name 2", timestamp: 10 }); 190 | assert.equal((await db.idName.get("did:example:b"))?.name, "name 2"); 191 | await db.idName.putIfNewer({ id: "did:example:b", name: "name 2a", timestamp: 11 }); 192 | assert.equal((await db.idName.get("did:example:b"))?.name, "name 2a"); 193 | await db.idName.putIfNewer({ id: "did:example:b", name: "name 2b", timestamp: 10 }); 194 | assert.equal((await db.idName.get("did:example:b"))?.name, "name 2a"); 195 | 196 | // doesn't update because not yet known 197 | await db.idName.updateIfNewer({ id: "did:example:c", name: "name 3", timestamp: 10 }); 198 | assert.ok(!(await db.idName.get("did:example:c"))); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /src/ldcontexts/credentials.ts: -------------------------------------------------------------------------------- 1 | export const credentials = { 2 | uri: "https://www.w3.org/2018/credentials/v1", 3 | ctx: { 4 | "@context": { 5 | "@version": 1.1, 6 | "@protected": true, 7 | 8 | id: "@id", 9 | type: "@type", 10 | 11 | VerifiableCredential: { 12 | "@id": "https://www.w3.org/2018/credentials#VerifiableCredential", 13 | "@context": { 14 | "@version": 1.1, 15 | "@protected": true, 16 | 17 | id: "@id", 18 | type: "@type", 19 | 20 | cred: "https://www.w3.org/2018/credentials#", 21 | sec: "https://w3id.org/security#", 22 | xsd: "http://www.w3.org/2001/XMLSchema#", 23 | 24 | credentialSchema: { 25 | "@id": "cred:credentialSchema", 26 | "@type": "@id", 27 | "@context": { 28 | "@version": 1.1, 29 | "@protected": true, 30 | 31 | id: "@id", 32 | type: "@type", 33 | 34 | cred: "https://www.w3.org/2018/credentials#", 35 | 36 | JsonSchemaValidator2018: "cred:JsonSchemaValidator2018", 37 | }, 38 | }, 39 | credentialStatus: { "@id": "cred:credentialStatus", "@type": "@id" }, 40 | credentialSubject: { "@id": "cred:credentialSubject", "@type": "@id" }, 41 | evidence: { "@id": "cred:evidence", "@type": "@id" }, 42 | expirationDate: { "@id": "cred:expirationDate", "@type": "xsd:dateTime" }, 43 | holder: { "@id": "cred:holder", "@type": "@id" }, 44 | issued: { "@id": "cred:issued", "@type": "xsd:dateTime" }, 45 | issuer: { "@id": "cred:issuer", "@type": "@id" }, 46 | issuanceDate: { "@id": "cred:issuanceDate", "@type": "xsd:dateTime" }, 47 | proof: { "@id": "sec:proof", "@type": "@id", "@container": "@graph" }, 48 | refreshService: { 49 | "@id": "cred:refreshService", 50 | "@type": "@id", 51 | "@context": { 52 | "@version": 1.1, 53 | "@protected": true, 54 | 55 | id: "@id", 56 | type: "@type", 57 | 58 | cred: "https://www.w3.org/2018/credentials#", 59 | 60 | ManualRefreshService2018: "cred:ManualRefreshService2018", 61 | }, 62 | }, 63 | termsOfUse: { "@id": "cred:termsOfUse", "@type": "@id" }, 64 | validFrom: { "@id": "cred:validFrom", "@type": "xsd:dateTime" }, 65 | validUntil: { "@id": "cred:validUntil", "@type": "xsd:dateTime" }, 66 | }, 67 | }, 68 | 69 | VerifiablePresentation: { 70 | "@id": "https://www.w3.org/2018/credentials#VerifiablePresentation", 71 | "@context": { 72 | "@version": 1.1, 73 | "@protected": true, 74 | 75 | id: "@id", 76 | type: "@type", 77 | 78 | cred: "https://www.w3.org/2018/credentials#", 79 | sec: "https://w3id.org/security#", 80 | 81 | holder: { "@id": "cred:holder", "@type": "@id" }, 82 | proof: { "@id": "sec:proof", "@type": "@id", "@container": "@graph" }, 83 | verifiableCredential: { 84 | "@id": "cred:verifiableCredential", 85 | "@type": "@id", 86 | "@container": "@graph", 87 | }, 88 | }, 89 | }, 90 | 91 | EcdsaSecp256k1Signature2019: { 92 | "@id": "https://w3id.org/security#EcdsaSecp256k1Signature2019", 93 | "@context": { 94 | "@version": 1.1, 95 | "@protected": true, 96 | 97 | id: "@id", 98 | type: "@type", 99 | 100 | sec: "https://w3id.org/security#", 101 | xsd: "http://www.w3.org/2001/XMLSchema#", 102 | 103 | challenge: "sec:challenge", 104 | created: { "@id": "http://purl.org/dc/terms/created", "@type": "xsd:dateTime" }, 105 | domain: "sec:domain", 106 | expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, 107 | jws: "sec:jws", 108 | nonce: "sec:nonce", 109 | proofPurpose: { 110 | "@id": "sec:proofPurpose", 111 | "@type": "@vocab", 112 | "@context": { 113 | "@version": 1.1, 114 | "@protected": true, 115 | 116 | id: "@id", 117 | type: "@type", 118 | 119 | sec: "https://w3id.org/security#", 120 | 121 | assertionMethod: { 122 | "@id": "sec:assertionMethod", 123 | "@type": "@id", 124 | "@container": "@set", 125 | }, 126 | authentication: { 127 | "@id": "sec:authenticationMethod", 128 | "@type": "@id", 129 | "@container": "@set", 130 | }, 131 | }, 132 | }, 133 | proofValue: "sec:proofValue", 134 | verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, 135 | }, 136 | }, 137 | 138 | EcdsaSecp256r1Signature2019: { 139 | "@id": "https://w3id.org/security#EcdsaSecp256r1Signature2019", 140 | "@context": { 141 | "@version": 1.1, 142 | "@protected": true, 143 | 144 | id: "@id", 145 | type: "@type", 146 | 147 | sec: "https://w3id.org/security#", 148 | xsd: "http://www.w3.org/2001/XMLSchema#", 149 | 150 | challenge: "sec:challenge", 151 | created: { "@id": "http://purl.org/dc/terms/created", "@type": "xsd:dateTime" }, 152 | domain: "sec:domain", 153 | expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, 154 | jws: "sec:jws", 155 | nonce: "sec:nonce", 156 | proofPurpose: { 157 | "@id": "sec:proofPurpose", 158 | "@type": "@vocab", 159 | "@context": { 160 | "@version": 1.1, 161 | "@protected": true, 162 | 163 | id: "@id", 164 | type: "@type", 165 | 166 | sec: "https://w3id.org/security#", 167 | 168 | assertionMethod: { 169 | "@id": "sec:assertionMethod", 170 | "@type": "@id", 171 | "@container": "@set", 172 | }, 173 | authentication: { 174 | "@id": "sec:authenticationMethod", 175 | "@type": "@id", 176 | "@container": "@set", 177 | }, 178 | }, 179 | }, 180 | proofValue: "sec:proofValue", 181 | verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, 182 | }, 183 | }, 184 | 185 | Ed25519Signature2018: { 186 | "@id": "https://w3id.org/security#Ed25519Signature2018", 187 | "@context": { 188 | "@version": 1.1, 189 | "@protected": true, 190 | 191 | id: "@id", 192 | type: "@type", 193 | 194 | sec: "https://w3id.org/security#", 195 | xsd: "http://www.w3.org/2001/XMLSchema#", 196 | 197 | challenge: "sec:challenge", 198 | created: { "@id": "http://purl.org/dc/terms/created", "@type": "xsd:dateTime" }, 199 | domain: "sec:domain", 200 | expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, 201 | jws: "sec:jws", 202 | nonce: "sec:nonce", 203 | proofPurpose: { 204 | "@id": "sec:proofPurpose", 205 | "@type": "@vocab", 206 | "@context": { 207 | "@version": 1.1, 208 | "@protected": true, 209 | 210 | id: "@id", 211 | type: "@type", 212 | 213 | sec: "https://w3id.org/security#", 214 | 215 | assertionMethod: { 216 | "@id": "sec:assertionMethod", 217 | "@type": "@id", 218 | "@container": "@set", 219 | }, 220 | authentication: { 221 | "@id": "sec:authenticationMethod", 222 | "@type": "@id", 223 | "@container": "@set", 224 | }, 225 | }, 226 | }, 227 | proofValue: "sec:proofValue", 228 | verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, 229 | }, 230 | }, 231 | 232 | RsaSignature2018: { 233 | "@id": "https://w3id.org/security#RsaSignature2018", 234 | "@context": { 235 | "@version": 1.1, 236 | "@protected": true, 237 | 238 | challenge: "sec:challenge", 239 | created: { "@id": "http://purl.org/dc/terms/created", "@type": "xsd:dateTime" }, 240 | domain: "sec:domain", 241 | expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, 242 | jws: "sec:jws", 243 | nonce: "sec:nonce", 244 | proofPurpose: { 245 | "@id": "sec:proofPurpose", 246 | "@type": "@vocab", 247 | "@context": { 248 | "@version": 1.1, 249 | "@protected": true, 250 | 251 | id: "@id", 252 | type: "@type", 253 | 254 | sec: "https://w3id.org/security#", 255 | 256 | assertionMethod: { 257 | "@id": "sec:assertionMethod", 258 | "@type": "@id", 259 | "@container": "@set", 260 | }, 261 | authentication: { 262 | "@id": "sec:authenticationMethod", 263 | "@type": "@id", 264 | "@container": "@set", 265 | }, 266 | }, 267 | }, 268 | proofValue: "sec:proofValue", 269 | verificationMethod: { "@id": "sec:verificationMethod", "@type": "@id" }, 270 | }, 271 | }, 272 | 273 | proof: { "@id": "https://w3id.org/security#proof", "@type": "@id", "@container": "@graph" }, 274 | }, 275 | }, 276 | }; 277 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chatter Net Client 2 | 3 | Chatter Net is a modern decentralized semantic web built atop self-sovereign identity. 4 | 5 | For more, you can have a look at the sibling [server project](https://github.com/chatternet/chatternet-server-http), 6 | and a prototype work-in-progress [social application](https://www.conversely.social) used to dog food the development process. 7 | 8 | **Warning**: Chatter Net is currently in the prototype phase. 9 | Features are missing, 10 | features are broken, 11 | and the public interface will change. 12 | 13 | ## Project Objectives 14 | 15 | Chatter Net is a platform which is: 16 | 17 | - **Open**: anyone can participate in, extend, and innovate on the platform. 18 | - **Decentralized**: there is no central point of failure. Network consensus determines what content arrives to a user. 19 | - **Self-moderating**: a user has enough control over what they receive to reject spam content. 20 | 21 | Chatter Net aims to solve the problem of central ownership of user identity. 22 | There are currently few organizations which control the vast majority of the identities of online users. 23 | When the objectives of these organizations and those of the users become misaligned, 24 | this can cause major problems for the users. 25 | 26 | After investing 100s or 1000s of hours into building a network and content on a platform, 27 | a user might be banned from the platform with no appeal process, 28 | a user's content might be subject to summarily deleted or otherwise made inaccessible with no explanation, 29 | a user might be asked to pay fees to continue accessing the content and network they built themselves, 30 | etc. 31 | 32 | The proposed solution is simple: 33 | allow a user to prove their identity to other users without relying on a 3rd party; 34 | and allow users verify the origin of some content without relying on a 3rd party. 35 | 36 | ## Examples 37 | 38 | Following is an example demonstrating how to: 39 | instantiate a client node, 40 | connect to some servers, 41 | and post a message to the network. 42 | In the examples, string enclosed in `<>` brackets are dummy values. 43 | 44 | ```typescript 45 | import { ChatterNet } from "chatternet-client-http"; 46 | const did = "did:key:"; 47 | const password = ""; 48 | const chatterNet = new ChatterNet( 49 | did, 50 | password, 51 | [ 52 | { 53 | did: "did:key:", 54 | url: "https://", 55 | }, 56 | { 57 | did: "did:key:", 58 | url: "https://", 59 | }, 60 | ], 61 | ); 62 | const { message, objects } = await chatterNet.newNote("Hi!"); 63 | chatterNet.postMessageObjectDoc(note); 64 | ``` 65 | 66 | The `ChatterNet.newNote` method builds an [Activity Stream](https://www.w3.org/ns/activitystreams) object of type `Create` whose object is a `Note`. 67 | The message is then signed with the client actor's key. 68 | 69 | The `message` variable is a [JSON-LD](https://json-ld.org/) objects similar to the following: 70 | 71 | ```json 72 | { 73 | { 74 | "@context": [ 75 | "https://www.w3.org/ns/activitystreams", 76 | "https://www.w3.org/2018/credentials/v1", 77 | "https://w3id.org/security/suites/ed25519-2020/v1" 78 | ], 79 | "id": "urn:cid:", 80 | "type": "Create", 81 | "actor": "did:key:/actor", 82 | "object": ["urn:cid:"], 83 | "published": "2000-01-01T00:00:00.000Z", 84 | "proof": { 85 | "type": "Ed25519Signature2020", 86 | "proofPurpose": "assertionMethod", 87 | "proofValue": "", 88 | "verificationMethod": "did:key:#", 89 | "created": "2000-00-00T00:00:00Z" 90 | }, 91 | "audience": ["did:key:/actor/followers"] 92 | } 93 | } 94 | ``` 95 | 96 | And the `objects` variable is a list such as: 97 | 98 | ```json 99 | [ 100 | { 101 | "@context": ["https://www.w3.org/ns/activitystreams"], 102 | "id": "urn:cid:", 103 | "type": "Note", 104 | "content": "Hi!", 105 | } 106 | ] 107 | ``` 108 | 109 | As you can see, the message is an activity (in this case of type `Create`), 110 | whose actor is the local client's user. 111 | And the object of the activity is just the ID of the note object. 112 | In this way, Chatter Net messages describe content, but do not contain that content. 113 | 114 | You can also create your own messages and objects and publish them to the network: 115 | 116 | ```typescript 117 | import { Messages } from "chatternet-client-http"; 118 | const did = "did:key:"; 119 | const content = "Hi!"; 120 | const mediaType = "application/xml"; 121 | const document = await Messages.newObjectDoc("Document", { content, mediaType }); 122 | const message = await chatterNet.newMessage([document.id], "Create"); 123 | chatterNet.postMessageObjectDoc({ message, objects: [document] }); 124 | ``` 125 | 126 | ## Technology 127 | 128 | Whereas the world wide web is a web of HTML documents, 129 | Chatter Net is a web of self-signed semantic documents. 130 | It closely follows (but is not fully compliant with) the [Activity Pub](https://www.w3.org/TR/activitypub/) protocol. 131 | Consequently, it is closely related to other [federated platforms](https://fediverse.party/), 132 | of which [Mastodon](https://joinmastodon.org/) is the a well established platform. 133 | 134 | Chatter Net's self-signed data model does differ in a subtle yet meaningful way: 135 | **the authority resides in the users of the network, not the servers**. 136 | 137 | This is what allows the project to realize its objectives. 138 | 139 | - No de-platforming: since no server is needed to verify the identity of a user, no server can prevent a user from accessing the network. 140 | - No platform lock-in: since no server is needed to verify the authenticity of data, no server can lock data away from users and other servers. 141 | - No spam from arbitrary users: would-be spammers need not only convince a server to trust them, they must directly convince other users. 142 | 143 | ### Data model 144 | 145 | [Activity Streams](https://www.w3.org/ns/activitystreams) is semantic, self-describing JSON data format. 146 | It can be used to describe arbitrary data as well as interactions between actors and the data. 147 | 148 | ### Identity 149 | 150 | The [DID Key](https://github.com/digitalbazaar/did-method-key/) standard uses public-private key pair cryptography to prove identity. 151 | An account is created locally by a user, 152 | and the private key created by that user allows them to prove their identity. 153 | [Verifiable Credential Proofs](https://w3c.github.io/vc-data-integrity/) allow the users to verify the authenticity of messages. 154 | 155 | ### Networking 156 | 157 | Chatter Net does not rely on a specific network stack or protocol. 158 | It is instead specified by its _data model_. 159 | It would be possible (though prohibitively slow) to operate a Chatter Net network using carrier pigeons. 160 | 161 | This library includes client functionality to communicate with a network of [HTTP servers](https://github.com/chatternet/chatternet-server-http/). 162 | Other network implementations could be added in the future. 163 | 164 | ## Roadmap 165 | 166 | There is a lot of work still needed to make this project workable. Here are some short term objectives: 167 | 168 | - Message deletion and unfollow. 169 | - Local message store. 170 | - Server selection and load balancing. 171 | - Account migration / recovery. 172 | 173 | ## Development 174 | 175 | ### Requirements 176 | 177 | The only system requirement is Node JS and a web browser. 178 | You can get Node JS on macOS or Linux with the following command: 179 | 180 | ```bash 181 | wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash 182 | nvm install node 183 | ``` 184 | 185 | ### Installation 186 | 187 | Get the source code using Git: 188 | 189 | ```bash 190 | git clone https://github.com/chatternet/chatternet-lib-js.git 191 | ``` 192 | 193 | Or by downloading and extracting from 194 | . 195 | 196 | Then install using NPM: 197 | 198 | ```bash 199 | npm install 200 | ``` 201 | 202 | ### Commands 203 | 204 | - `npm run clean`: remove the build artifacts 205 | - `npm run fmt`: format all source code 206 | - `npm run build`: build the package 207 | - `npm run test`: run all tests (in a node environment) 208 | 209 | ### Package configuration 210 | 211 | Package building is handled by `aegir`: 212 | . 213 | This is a Typescript template which necessitates further configuration: 214 | . 215 | 216 | #### package.json 217 | 218 | - The `types` key is set to `module` such that the project is exported as an ESM. 219 | - TS types are output at `dist/src/index.d.ts`. 220 | - The `files` key avoids packaging the compiled tests. 221 | - The `exports` key specifies which module exports can be imported from the package. 222 | 223 | ### Testing 224 | 225 | NOTE: you will need a node version >= 19.0.0 to run the test suite. 226 | 227 | Test are added to the `test` directory with the suffix `.spec.ts`. 228 | They can import from `src` using Typescript imports and ESM imports. 229 | 230 | The tests are themselves built and output in `dist/test`. 231 | From there, they import from the built `dist/src`. 232 | In this way the tests run as compiled JS, 233 | calling code from the distributed module. 234 | 235 | To run integration tests against a server, 236 | set the environment variable `CHATTERNET_TEST_SERVER` to the `ServerInfo` json describing the test server. 237 | 238 | If you are running a `chatternet-server-http` server, 239 | it will output the file `server-info.json` to the directory from which it is run. 240 | You can then do something like: 241 | 242 | ```bash 243 | CHATTERNET_TEST_SERVER=$(cat $SERVER/server-info.json) npm run test -- -- -f 'chatter net builds new' 244 | ``` 245 | -------------------------------------------------------------------------------- /types/crypto-ld/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "crypto-ld" { 2 | /*! 3 | * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. 4 | */ 5 | 6 | /** 7 | * General purpose key generation driver for Linked Data cryptographic key 8 | * pairs. 9 | * 10 | * @param {Map} [suites] - Optional map of supported suites, by suite id. 11 | */ 12 | export class CryptoLD { 13 | constructor({ suites } = {}); 14 | 15 | /** 16 | * Installs support for a key type (suite). 17 | * 18 | * @param {LDKeyPair} keyPairLib - Conforming key pair library for a suite. 19 | */ 20 | use(keyPairLib); 21 | 22 | /** 23 | * Generates a public/private LDKeyPair. 24 | * 25 | * @param {object} options - Suite-specific key options. 26 | * @param {string} options.type - Key suite id (for example, 27 | * 'Ed25519VerificationKey2020'). 28 | * @param {string} [options.controller] - Controller DID or URL for the 29 | * generated key pair. If present, used to auto-initialize the key.id. 30 | * 31 | * @returns {Promise} Generated key pair. 32 | */ 33 | async generate(options = {}); 34 | 35 | /** 36 | * Imports a public/private key pair from serialized data. 37 | * 38 | * @param {object} serialized - Serialized key object. 39 | * 40 | * @throws {Error} - On missing or invalid serialized key data. 41 | * 42 | * @returns {Promise} Imported key pair. 43 | */ 44 | async from(serialized = {}); 45 | 46 | /** 47 | * Imports a key pair instance from a provided externally fetched key 48 | * document (fetched via a secure JSON-LD `documentLoader` or via 49 | * `cryptoLd.fromKeyId()`), optionally checking it for revocation and required 50 | * context. 51 | * 52 | * @param {object} options - Options hashmap. 53 | * @param {string} options.document - Externally fetched key document. 54 | * @param {boolean} [options.checkContext=true] - Whether to check that the 55 | * fetched key document contains the context required by the key's crypto 56 | * suite. 57 | * @param {boolean} [options.checkRevoked=true] - Whether to check the key 58 | * object for the presence of the `revoked` timestamp. 59 | * 60 | * @returns {Promise} Resolves with the resulting key pair 61 | * instance. 62 | */ 63 | async fromKeyDocument({ document, checkContext = true, checkRevoked = true } = {}); 64 | 65 | /** 66 | * Imports a key pair instance via the provided `documentLoader` function, 67 | * optionally checking it for revocation and required context. 68 | * 69 | * @param {object} options - Options hashmap. 70 | * @param {string} options.id - Key ID or URI. 71 | * @param {Function} options.documentLoader - JSON-LD Document Loader. 72 | * @param {boolean} [options.checkContext=true] - Whether to check that the 73 | * fetched key document contains the context required by the key's crypto 74 | * suite. 75 | * @param {boolean} [options.checkRevoked=true] - Whether to check the key 76 | * object for the presence of the `revoked` timestamp. 77 | * 78 | * @returns {Promise} Resolves with the appropriate key pair 79 | * instance. 80 | */ 81 | async fromKeyId({ id, documentLoader, checkContext = true, checkRevoked = true } = {}); 82 | } 83 | 84 | /** 85 | * When adding support for a new suite type for `crypto-ld`, developers should 86 | * do the following: 87 | * 88 | * 1. Create their own npm package / github repo, such as `example-key-pair`. 89 | * 2. Subclass LDKeyPair. 90 | * 3. Override relevant methods (such as `export()` and `fingerprint()`). 91 | * 4. Add to the key type table in the `crypto-ld` README.md (that's this repo). 92 | */ 93 | export class LDKeyPair { 94 | /* eslint-disable jsdoc/require-description-complete-sentence */ 95 | /** 96 | * Creates a public/private key pair instance. This is an abstract base class, 97 | * actual key material and suite-specific methods are handled in the subclass. 98 | * 99 | * To generate or import a key pair, use the `cryptoLd` instance. 100 | * 101 | * @see CryptoLD.js 102 | * 103 | * @param {object} options - The options to use. 104 | * @param {string} options.id - The key id, typically composed of controller 105 | * URL and key fingerprint as hash fragment. 106 | * @param {string} options.controller - DID/URL of the person/entity 107 | * controlling this key. 108 | * @param {string} [options.revoked] - Timestamp of when the key has been 109 | * revoked, in RFC3339 format. If not present, the key itself is 110 | * considered not revoked. (Note that this mechanism is slightly different 111 | * than DID Document key revocation, where a DID controller can revoke a 112 | * key from that DID by removing it from the DID Document.) 113 | */ 114 | /* eslint-enable */ 115 | constructor({ id, controller, revoked } = {}); 116 | 117 | /* eslint-disable jsdoc/check-param-names */ 118 | /** 119 | * Generates a new public/private key pair instance. 120 | * Note that this method is not typically called directly by client code, 121 | * but instead is used through a `cryptoLd` instance. 122 | * 123 | * @param {object} options - Suite-specific options for the KeyPair. For 124 | * common options, see the `LDKeyPair.constructor()` docstring. 125 | * 126 | * @returns {Promise} An LDKeyPair instance. 127 | */ 128 | /* eslint-enable */ 129 | static async generate(/* options */); 130 | 131 | /** 132 | * Imports a key pair instance from a provided externally fetched key 133 | * document (fetched via a secure JSON-LD `documentLoader` or via 134 | * `cryptoLd.fromKeyId()`), optionally checking it for revocation and required 135 | * context. 136 | * 137 | * @param {object} options - Options hashmap. 138 | * @param {string} options.document - Externally fetched key document. 139 | * @param {boolean} [options.checkContext=true] - Whether to check that the 140 | * fetched key document contains the context required by the key's crypto 141 | * suite. 142 | * @param {boolean} [options.checkRevoked=true] - Whether to check the key 143 | * object for the presence of the `revoked` timestamp. 144 | * 145 | * @returns {Promise} Resolves with the resulting key pair 146 | * instance. 147 | */ 148 | static async fromKeyDocument({ document, checkContext = true, checkRevoked = true } = {}); 149 | 150 | /* eslint-disable jsdoc/check-param-names */ 151 | /** 152 | * Generates a KeyPair from some options. 153 | * 154 | * @param {object} options - Will generate a key pair in multiple different 155 | * formats. 156 | * @example 157 | * > const options = { 158 | * type: 'Ed25519VerificationKey2020' 159 | * }; 160 | * > const edKeyPair = await LDKeyPair.from(options); 161 | * 162 | * @returns {Promise} A LDKeyPair. 163 | * @throws Unsupported Key Type. 164 | */ 165 | /* eslint-enable */ 166 | static async from(/* options */); 167 | 168 | /** 169 | * Exports the serialized representation of the KeyPair 170 | * and other information that json-ld Signatures can use to form a proof. 171 | * 172 | * NOTE: Subclasses MUST override this method (and add the exporting of 173 | * their public and private key material). 174 | * 175 | * @param {object} [options={}] - Options hashmap. 176 | * @param {boolean} [options.publicKey] - Export public key material? 177 | * @param {boolean} [options.privateKey] - Export private key material? 178 | * 179 | * @returns {object} A public key object 180 | * information used in verification methods by signatures. 181 | */ 182 | export({ publicKey = false, privateKey = false } = {}); 183 | 184 | /** 185 | * Returns the public key fingerprint, multibase+multicodec encoded. The 186 | * specific fingerprint method is determined by the key suite, and is often 187 | * either a hash of the public key material (such as with RSA), or the 188 | * full encoded public key (for key types with sufficiently short 189 | * representations, such as ed25519). 190 | * This is frequently used in initializing the key id, or generating some 191 | * types of cryptonym DIDs. 192 | * 193 | * @returns {string} The fingerprint. 194 | */ 195 | fingerprint(); 196 | 197 | /* eslint-disable jsdoc/check-param-names */ 198 | /** 199 | * Verifies that a given key fingerprint matches the public key material 200 | * belonging to this key pair. 201 | * 202 | * @param {string} fingerprint - Public key fingerprint. 203 | * 204 | * @returns {{verified: boolean}} An object with verified flag. 205 | */ 206 | /* eslint-enable */ 207 | verifyFingerprint(/* {fingerprint} */); 208 | 209 | /* eslint-disable max-len */ 210 | /** 211 | * Returns a signer object for use with 212 | * [jsonld-signatures]{@link https://github.com/digitalbazaar/jsonld-signatures}. 213 | * NOTE: Applies only to verifier type keys (like ed25519). 214 | * 215 | * @example 216 | * > const signer = keyPair.signer(); 217 | * > signer 218 | * { sign: [AsyncFunction: sign] } 219 | * > signer.sign({data}); 220 | * 221 | * @returns {{sign: Function}} A signer for json-ld usage. 222 | */ 223 | /* eslint-enable */ 224 | signer(); 225 | 226 | /* eslint-disable max-len */ 227 | /** 228 | * Returns a verifier object for use with 229 | * [jsonld-signatures]{@link https://github.com/digitalbazaar/jsonld-signatures}. 230 | * NOTE: Applies only to verifier type keys (like ed25519). 231 | * 232 | * @example 233 | * > const verifier = keyPair.verifier(); 234 | * > verifier 235 | * { verify: [AsyncFunction: verify] } 236 | * > verifier.verify(key); 237 | * 238 | * @returns {{verify: Function}} Used to verify jsonld-signatures. 239 | */ 240 | /* eslint-enable */ 241 | verifier(); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/ldcontexts/activitystreams.ts: -------------------------------------------------------------------------------- 1 | export const activitystreams = { 2 | uri: "https://www.w3.org/ns/activitystreams", 3 | ctx: { 4 | "@context": { 5 | "@vocab": "_:", 6 | xsd: "http://www.w3.org/2001/XMLSchema#", 7 | as: "https://www.w3.org/ns/activitystreams#", 8 | ldp: "http://www.w3.org/ns/ldp#", 9 | vcard: "http://www.w3.org/2006/vcard/ns#", 10 | id: "@id", 11 | type: "@type", 12 | Accept: "as:Accept", 13 | Activity: "as:Activity", 14 | IntransitiveActivity: "as:IntransitiveActivity", 15 | Add: "as:Add", 16 | Announce: "as:Announce", 17 | Application: "as:Application", 18 | Arrive: "as:Arrive", 19 | Article: "as:Article", 20 | Audio: "as:Audio", 21 | Block: "as:Block", 22 | Collection: "as:Collection", 23 | CollectionPage: "as:CollectionPage", 24 | Relationship: "as:Relationship", 25 | Create: "as:Create", 26 | Delete: "as:Delete", 27 | Dislike: "as:Dislike", 28 | Document: "as:Document", 29 | Event: "as:Event", 30 | Follow: "as:Follow", 31 | Flag: "as:Flag", 32 | Group: "as:Group", 33 | Ignore: "as:Ignore", 34 | Image: "as:Image", 35 | Invite: "as:Invite", 36 | Join: "as:Join", 37 | Leave: "as:Leave", 38 | Like: "as:Like", 39 | Link: "as:Link", 40 | Mention: "as:Mention", 41 | Note: "as:Note", 42 | Object: "as:Object", 43 | Offer: "as:Offer", 44 | OrderedCollection: "as:OrderedCollection", 45 | OrderedCollectionPage: "as:OrderedCollectionPage", 46 | Organization: "as:Organization", 47 | Page: "as:Page", 48 | Person: "as:Person", 49 | Place: "as:Place", 50 | Profile: "as:Profile", 51 | Question: "as:Question", 52 | Reject: "as:Reject", 53 | Remove: "as:Remove", 54 | Service: "as:Service", 55 | TentativeAccept: "as:TentativeAccept", 56 | TentativeReject: "as:TentativeReject", 57 | Tombstone: "as:Tombstone", 58 | Undo: "as:Undo", 59 | Update: "as:Update", 60 | Video: "as:Video", 61 | View: "as:View", 62 | Listen: "as:Listen", 63 | Read: "as:Read", 64 | Move: "as:Move", 65 | Travel: "as:Travel", 66 | IsFollowing: "as:IsFollowing", 67 | IsFollowedBy: "as:IsFollowedBy", 68 | IsContact: "as:IsContact", 69 | IsMember: "as:IsMember", 70 | subject: { 71 | "@id": "as:subject", 72 | "@type": "@id", 73 | }, 74 | relationship: { 75 | "@id": "as:relationship", 76 | "@type": "@id", 77 | }, 78 | actor: { 79 | "@id": "as:actor", 80 | "@type": "@id", 81 | }, 82 | attributedTo: { 83 | "@id": "as:attributedTo", 84 | "@type": "@id", 85 | }, 86 | attachment: { 87 | "@id": "as:attachment", 88 | "@type": "@id", 89 | }, 90 | bcc: { 91 | "@id": "as:bcc", 92 | "@type": "@id", 93 | }, 94 | bto: { 95 | "@id": "as:bto", 96 | "@type": "@id", 97 | }, 98 | cc: { 99 | "@id": "as:cc", 100 | "@type": "@id", 101 | }, 102 | context: { 103 | "@id": "as:context", 104 | "@type": "@id", 105 | }, 106 | current: { 107 | "@id": "as:current", 108 | "@type": "@id", 109 | }, 110 | first: { 111 | "@id": "as:first", 112 | "@type": "@id", 113 | }, 114 | generator: { 115 | "@id": "as:generator", 116 | "@type": "@id", 117 | }, 118 | icon: { 119 | "@id": "as:icon", 120 | "@type": "@id", 121 | }, 122 | image: { 123 | "@id": "as:image", 124 | "@type": "@id", 125 | }, 126 | inReplyTo: { 127 | "@id": "as:inReplyTo", 128 | "@type": "@id", 129 | }, 130 | items: { 131 | "@id": "as:items", 132 | "@type": "@id", 133 | }, 134 | instrument: { 135 | "@id": "as:instrument", 136 | "@type": "@id", 137 | }, 138 | orderedItems: { 139 | "@id": "as:items", 140 | "@type": "@id", 141 | "@container": "@list", 142 | }, 143 | last: { 144 | "@id": "as:last", 145 | "@type": "@id", 146 | }, 147 | location: { 148 | "@id": "as:location", 149 | "@type": "@id", 150 | }, 151 | next: { 152 | "@id": "as:next", 153 | "@type": "@id", 154 | }, 155 | object: { 156 | "@id": "as:object", 157 | "@type": "@id", 158 | }, 159 | oneOf: { 160 | "@id": "as:oneOf", 161 | "@type": "@id", 162 | }, 163 | anyOf: { 164 | "@id": "as:anyOf", 165 | "@type": "@id", 166 | }, 167 | closed: { 168 | "@id": "as:closed", 169 | "@type": "xsd:dateTime", 170 | }, 171 | origin: { 172 | "@id": "as:origin", 173 | "@type": "@id", 174 | }, 175 | accuracy: { 176 | "@id": "as:accuracy", 177 | "@type": "xsd:float", 178 | }, 179 | prev: { 180 | "@id": "as:prev", 181 | "@type": "@id", 182 | }, 183 | preview: { 184 | "@id": "as:preview", 185 | "@type": "@id", 186 | }, 187 | replies: { 188 | "@id": "as:replies", 189 | "@type": "@id", 190 | }, 191 | result: { 192 | "@id": "as:result", 193 | "@type": "@id", 194 | }, 195 | audience: { 196 | "@id": "as:audience", 197 | "@type": "@id", 198 | }, 199 | partOf: { 200 | "@id": "as:partOf", 201 | "@type": "@id", 202 | }, 203 | tag: { 204 | "@id": "as:tag", 205 | "@type": "@id", 206 | }, 207 | target: { 208 | "@id": "as:target", 209 | "@type": "@id", 210 | }, 211 | to: { 212 | "@id": "as:to", 213 | "@type": "@id", 214 | }, 215 | url: { 216 | "@id": "as:url", 217 | "@type": "@id", 218 | }, 219 | altitude: { 220 | "@id": "as:altitude", 221 | "@type": "xsd:float", 222 | }, 223 | content: "as:content", 224 | contentMap: { 225 | "@id": "as:content", 226 | "@container": "@language", 227 | }, 228 | name: "as:name", 229 | nameMap: { 230 | "@id": "as:name", 231 | "@container": "@language", 232 | }, 233 | duration: { 234 | "@id": "as:duration", 235 | "@type": "xsd:duration", 236 | }, 237 | endTime: { 238 | "@id": "as:endTime", 239 | "@type": "xsd:dateTime", 240 | }, 241 | height: { 242 | "@id": "as:height", 243 | "@type": "xsd:nonNegativeInteger", 244 | }, 245 | href: { 246 | "@id": "as:href", 247 | "@type": "@id", 248 | }, 249 | hreflang: "as:hreflang", 250 | latitude: { 251 | "@id": "as:latitude", 252 | "@type": "xsd:float", 253 | }, 254 | longitude: { 255 | "@id": "as:longitude", 256 | "@type": "xsd:float", 257 | }, 258 | mediaType: "as:mediaType", 259 | published: { 260 | "@id": "as:published", 261 | "@type": "xsd:dateTime", 262 | }, 263 | radius: { 264 | "@id": "as:radius", 265 | "@type": "xsd:float", 266 | }, 267 | rel: "as:rel", 268 | startIndex: { 269 | "@id": "as:startIndex", 270 | "@type": "xsd:nonNegativeInteger", 271 | }, 272 | startTime: { 273 | "@id": "as:startTime", 274 | "@type": "xsd:dateTime", 275 | }, 276 | summary: "as:summary", 277 | summaryMap: { 278 | "@id": "as:summary", 279 | "@container": "@language", 280 | }, 281 | totalItems: { 282 | "@id": "as:totalItems", 283 | "@type": "xsd:nonNegativeInteger", 284 | }, 285 | units: "as:units", 286 | updated: { 287 | "@id": "as:updated", 288 | "@type": "xsd:dateTime", 289 | }, 290 | width: { 291 | "@id": "as:width", 292 | "@type": "xsd:nonNegativeInteger", 293 | }, 294 | describes: { 295 | "@id": "as:describes", 296 | "@type": "@id", 297 | }, 298 | formerType: { 299 | "@id": "as:formerType", 300 | "@type": "@id", 301 | }, 302 | deleted: { 303 | "@id": "as:deleted", 304 | "@type": "xsd:dateTime", 305 | }, 306 | inbox: { 307 | "@id": "ldp:inbox", 308 | "@type": "@id", 309 | }, 310 | outbox: { 311 | "@id": "as:outbox", 312 | "@type": "@id", 313 | }, 314 | following: { 315 | "@id": "as:following", 316 | "@type": "@id", 317 | }, 318 | followers: { 319 | "@id": "as:followers", 320 | "@type": "@id", 321 | }, 322 | streams: { 323 | "@id": "as:streams", 324 | "@type": "@id", 325 | }, 326 | preferredUsername: "as:preferredUsername", 327 | endpoints: { 328 | "@id": "as:endpoints", 329 | "@type": "@id", 330 | }, 331 | uploadMedia: { 332 | "@id": "as:uploadMedia", 333 | "@type": "@id", 334 | }, 335 | proxyUrl: { 336 | "@id": "as:proxyUrl", 337 | "@type": "@id", 338 | }, 339 | liked: { 340 | "@id": "as:liked", 341 | "@type": "@id", 342 | }, 343 | oauthAuthorizationEndpoint: { 344 | "@id": "as:oauthAuthorizationEndpoint", 345 | "@type": "@id", 346 | }, 347 | oauthTokenEndpoint: { 348 | "@id": "as:oauthTokenEndpoint", 349 | "@type": "@id", 350 | }, 351 | provideClientKey: { 352 | "@id": "as:provideClientKey", 353 | "@type": "@id", 354 | }, 355 | signClientKey: { 356 | "@id": "as:signClientKey", 357 | "@type": "@id", 358 | }, 359 | sharedInbox: { 360 | "@id": "as:sharedInbox", 361 | "@type": "@id", 362 | }, 363 | Public: { 364 | "@id": "as:Public", 365 | "@type": "@id", 366 | }, 367 | source: "as:source", 368 | likes: { 369 | "@id": "as:likes", 370 | "@type": "@id", 371 | }, 372 | shares: { 373 | "@id": "as:shares", 374 | "@type": "@id", 375 | }, 376 | alsoKnownAs: { 377 | "@id": "as:alsoKnownAs", 378 | "@type": "@id", 379 | }, 380 | }, 381 | }, 382 | }; 383 | -------------------------------------------------------------------------------- /types/@digitalbazaar_vc/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@digitalbazaar/vc" { 2 | /** 3 | * A JavaScript implementation of Verifiable Credentials. 4 | * 5 | * @author Dave Longley 6 | * @author David I. Lehn 7 | * 8 | * @license BSD 3-Clause License 9 | * Copyright (c) 2017-2022 Digital Bazaar, Inc. 10 | * All rights reserved. 11 | * 12 | * Redistribution and use in source and binary forms, with or without 13 | * modification, are permitted provided that the following conditions are met: 14 | * 15 | * Redistributions of source code must retain the above copyright notice, 16 | * this list of conditions and the following disclaimer. 17 | * 18 | * Redistributions in binary form must reproduce the above copyright 19 | * notice, this list of conditions and the following disclaimer in the 20 | * documentation and/or other materials provided with the distribution. 21 | * 22 | * Neither the name of the Digital Bazaar, Inc. nor the names of its 23 | * contributors may be used to endorse or promote products derived from 24 | * this software without specific prior written permission. 25 | * 26 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 27 | * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 28 | * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 29 | * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 30 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 31 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 32 | * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 33 | * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 34 | * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 35 | * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 36 | * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 37 | */ 38 | 39 | /** 40 | * Creates a proof purpose that will validate whether or not the verification 41 | * method in a proof was authorized by its declared controller for the 42 | * proof's purpose. 43 | */ 44 | export class CredentialIssuancePurpose extends AssertionProofPurpose { 45 | /** 46 | * @param {object} options - The options to use. 47 | * @param {object} [options.controller] - The description of the controller, 48 | * if it is not to be dereferenced via a `documentLoader`. 49 | * @param {string|Date|number} [options.date] - The expected date for 50 | * the creation of the proof. 51 | * @param {number} [options.maxTimestampDelta=Infinity] - A maximum number 52 | * of seconds that the date on the signature can deviate from. 53 | */ 54 | constructor(options = {}); 55 | 56 | /** 57 | * Validates the purpose of a proof. This method is called during 58 | * proof verification, after the proof value has been checked against the 59 | * given verification method (in the case of a digital signature, the 60 | * signature has been cryptographically verified against the public key). 61 | * 62 | * @param {object} proof - The proof to validate. 63 | * @param {object} options - The options to use. 64 | * @param {object} options.document - The document whose signature is 65 | * being verified. 66 | * @param {object} options.suite - Signature suite used in 67 | * the proof. 68 | * @param {string} options.verificationMethod - Key id URL to the paired 69 | * public key. 70 | * @param {object} [options.documentLoader] - A document loader. 71 | * @param {object} [options.expansionMap] - An expansion map. 72 | * 73 | * @throws {Error} If verification method not authorized by controller. 74 | * @throws {Error} If proof's created timestamp is out of range. 75 | * 76 | * @returns {Promise<{valid: boolean, error: Error}>} Resolves on completion. 77 | */ 78 | async validate(proof, { document, suite, verificationMethod, documentLoader, expansionMap }); 79 | } 80 | 81 | // Z and T can be lowercase 82 | // RFC3339 regex 83 | export const dateRegex: RegExp; 84 | 85 | /** 86 | * @typedef {object} LinkedDataSignature 87 | */ 88 | 89 | /** 90 | * @typedef {object} Presentation 91 | */ 92 | 93 | /** 94 | * @typedef {object} ProofPurpose 95 | */ 96 | 97 | /** 98 | * @typedef {object} VerifiableCredential 99 | */ 100 | 101 | /** 102 | * @typedef {object} VerifiablePresentation 103 | */ 104 | 105 | /** 106 | * @typedef {object} VerifyPresentationResult 107 | * @property {boolean} verified - True if verified, false if not. 108 | * @property {object} presentationResult 109 | * @property {Array} credentialResults 110 | * @property {object} error 111 | */ 112 | 113 | /** 114 | * @typedef {object} VerifyCredentialResult 115 | * @property {boolean} verified - True if verified, false if not. 116 | * @property {object} statusResult 117 | * @property {Array} results 118 | * @property {object} error 119 | */ 120 | 121 | /** 122 | * Issues a verifiable credential (by taking a base credential document, 123 | * and adding a digital signature to it). 124 | * 125 | * @param {object} [options={}] - The options to use. 126 | * 127 | * @param {object} options.credential - Base credential document. 128 | * @param {LinkedDataSignature} options.suite - Signature suite (with private 129 | * key material), passed in to sign(). 130 | * 131 | * @param {ProofPurpose} [options.purpose] - A ProofPurpose. If not specified, 132 | * a default purpose will be created. 133 | * 134 | * Other optional params passed to `sign()`: 135 | * @param {object} [options.documentLoader] - A document loader. 136 | * @param {object} [options.expansionMap] - An expansion map. 137 | * @param {string|Date} [options.now] - A string representing date time in 138 | * ISO 8601 format or an instance of Date. Defaults to current date time. 139 | * 140 | * @throws {Error} If missing required properties. 141 | * 142 | * @returns {Promise} Resolves on completion. 143 | */ 144 | export async function issue(options = {}); 145 | 146 | /** 147 | * Verifies a verifiable presentation: 148 | * - Checks that the presentation is well-formed 149 | * - Checks the proofs (for example, checks digital signatures against the 150 | * provided public keys). 151 | * 152 | * @param {object} [options={}] - The options to use. 153 | * 154 | * @param {VerifiablePresentation} options.presentation - Verifiable 155 | * presentation, signed or unsigned, that may contain within it a 156 | * verifiable credential. 157 | * 158 | * @param {LinkedDataSignature|LinkedDataSignature[]} options.suite - One or 159 | * more signature suites that are supported by the caller's use case. This is 160 | * an explicit design decision -- the calling code must specify which 161 | * signature types (ed25519, RSA, etc) are allowed. 162 | * Although it is expected that the secure resolution/fetching of the public 163 | * key material (to verify against) is to be handled by the documentLoader, 164 | * the suite param can optionally include the key directly. 165 | * 166 | * @param {boolean} [options.unsignedPresentation=false] - By default, this 167 | * function assumes that a presentation is signed (and will return an error if 168 | * a `proof` section is missing). Set this to `true` if you're using an 169 | * unsigned presentation. 170 | * 171 | * Either pass in a proof purpose, 172 | * @param {AuthenticationProofPurpose} [options.presentationPurpose] - Optional 173 | * proof purpose (a default one will be created if not passed in). 174 | * 175 | * or a default purpose will be created with params: 176 | * @param {string} [options.challenge] - Required if purpose is not passed in. 177 | * @param {string} [options.controller] - A controller. 178 | * @param {string} [options.domain] - A domain. 179 | * 180 | * @param {Function} [options.documentLoader] - A document loader. 181 | * @param {Function} [options.checkStatus] - Optional function for checking 182 | * credential status if `credentialStatus` is present on the credential. 183 | * @param {string|Date} [options.now] - A string representing date time in 184 | * ISO 8601 format or an instance of Date. Defaults to current date time. 185 | * 186 | * @returns {Promise} The verification result. 187 | */ 188 | export async function verify(options = {}); 189 | 190 | /** 191 | * Verifies a verifiable credential: 192 | * - Checks that the credential is well-formed 193 | * - Checks the proofs (for example, checks digital signatures against the 194 | * provided public keys). 195 | * 196 | * @param {object} [options={}] - The options. 197 | * 198 | * @param {object} options.credential - Verifiable credential. 199 | * 200 | * @param {LinkedDataSignature|LinkedDataSignature[]} options.suite - One or 201 | * more signature suites that are supported by the caller's use case. This is 202 | * an explicit design decision -- the calling code must specify which 203 | * signature types (ed25519, RSA, etc) are allowed. 204 | * Although it is expected that the secure resolution/fetching of the public 205 | * key material (to verify against) is to be handled by the documentLoader, 206 | * the suite param can optionally include the key directly. 207 | * 208 | * @param {CredentialIssuancePurpose} [options.purpose] - Optional 209 | * proof purpose (a default one will be created if not passed in). 210 | * @param {Function} [options.documentLoader] - A document loader. 211 | * @param {Function} [options.checkStatus] - Optional function for checking 212 | * credential status if `credentialStatus` is present on the credential. 213 | * @param {string|Date} [options.now] - A string representing date time in 214 | * ISO 8601 format or an instance of Date. Defaults to current date time. 215 | * 216 | * @returns {Promise} The verification result. 217 | */ 218 | export async function verifyCredential(options = {}); 219 | 220 | /** 221 | * Creates an unsigned presentation from a given verifiable credential. 222 | * 223 | * @param {object} options - Options to use. 224 | * @param {object|Array} [options.verifiableCredential] - One or more 225 | * verifiable credential. 226 | * @param {string} [options.id] - Optional VP id. 227 | * @param {string} [options.holder] - Optional presentation holder url. 228 | * @param {string|Date} [options.now] - A string representing date time in 229 | * ISO 8601 format or an instance of Date. Defaults to current date time. 230 | * 231 | * @throws {TypeError} If verifiableCredential param is missing. 232 | * @throws {Error} If the credential (or the presentation params) are missing 233 | * required properties. 234 | * 235 | * @returns {Presentation} The credential wrapped inside of a 236 | * VerifiablePresentation. 237 | */ 238 | export function createPresentation(options = {}); 239 | 240 | /** 241 | * Signs a given presentation. 242 | * 243 | * @param {object} [options={}] - Options to use. 244 | * 245 | * Required: 246 | * @param {Presentation} options.presentation - A presentation. 247 | * @param {LinkedDataSignature} options.suite - passed in to sign() 248 | * 249 | * Either pass in a ProofPurpose, or a default one will be created with params: 250 | * @param {ProofPurpose} [options.purpose] - A ProofPurpose. If not specified, 251 | * a default purpose will be created with the domain and challenge options. 252 | * 253 | * @param {string} [options.domain] - A domain. 254 | * @param {string} options.challenge - A required challenge. 255 | * 256 | * @param {Function} [options.documentLoader] - A document loader. 257 | * 258 | * @returns {Promise<{VerifiablePresentation}>} A VerifiablePresentation with 259 | * a proof. 260 | */ 261 | export async function signPresentation(options = {}); 262 | } 263 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | import { didFromKey } from "./didkey.js"; 2 | import type { Model } from "./index.js"; 3 | import type { Key } from "./signatures.js"; 4 | import { Ed25519VerificationKey2020 } from "@digitalbazaar/ed25519-verification-key-2020"; 5 | import { IDBPDatabase, openDB } from "idb/with-async-ittr"; 6 | 7 | const DB_VERSION = 5; 8 | 9 | export interface IdName { 10 | id: string; 11 | name?: string; 12 | timestamp: number; 13 | } 14 | 15 | export interface ServerInfo { 16 | url: string; 17 | did: string; 18 | } 19 | 20 | export async function cryptoKeyFromPassword( 21 | password: string, 22 | salt: Uint8Array 23 | ): Promise { 24 | const keyMaterial = await window.crypto.subtle.importKey( 25 | "raw", 26 | new TextEncoder().encode(password), 27 | "PBKDF2", 28 | false, 29 | ["deriveBits", "deriveKey"] 30 | ); 31 | return await window.crypto.subtle.deriveKey( 32 | { 33 | name: "PBKDF2", 34 | salt, 35 | iterations: 100_000, 36 | hash: "SHA-256", 37 | }, 38 | keyMaterial, 39 | { name: "AES-GCM", length: 256 }, 40 | true, 41 | ["encrypt", "decrypt"] 42 | ); 43 | } 44 | 45 | export interface RecordIdSalt { 46 | id: string; 47 | salt: Uint8Array; 48 | } 49 | 50 | class StoreIdSalt { 51 | static DEFAULT_NAME = "IdSalt"; 52 | 53 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreIdSalt.DEFAULT_NAME) {} 54 | 55 | static create(db: IDBPDatabase, name: string = StoreIdSalt.DEFAULT_NAME) { 56 | db.createObjectStore(name, { keyPath: "id" }); 57 | return new StoreIdSalt(db, name); 58 | } 59 | 60 | async clear() { 61 | await this.db.transaction(this.name, "readwrite").store.clear(); 62 | } 63 | 64 | async getPut(id: string): Promise { 65 | const transaction = this.db.transaction(this.name, "readwrite"); 66 | const prevRecord: RecordIdSalt | undefined = await transaction.store.get(id); 67 | if (prevRecord) return prevRecord.salt; 68 | const salt = window.crypto.getRandomValues(new Uint8Array(32)); 69 | const record: RecordIdSalt = { id, salt }; 70 | await transaction.store.put(record); 71 | return salt; 72 | } 73 | } 74 | 75 | interface RecordKeyPair { 76 | did: string; 77 | ciphertext: ArrayBuffer; 78 | iv: Uint8Array; 79 | } 80 | 81 | class StoreKeyPair { 82 | static DEFAULT_NAME = "KeyPair"; 83 | 84 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreKeyPair.DEFAULT_NAME) {} 85 | 86 | static create(db: IDBPDatabase, name: string = StoreKeyPair.DEFAULT_NAME) { 87 | db.createObjectStore(name, { keyPath: "did" }); 88 | return new StoreKeyPair(db, name); 89 | } 90 | 91 | async clear() { 92 | await this.db.transaction(this.name, "readwrite").store.clear(); 93 | } 94 | 95 | async put(key: Key, cryptoKey: CryptoKey) { 96 | const did = didFromKey(key); 97 | const exported = key.export({ publicKey: true, privateKey: true }); 98 | const plaintext = new TextEncoder().encode(JSON.stringify(exported)); 99 | const iv = window.crypto.getRandomValues(new Uint8Array(12)); 100 | const ciphertext = await window.crypto.subtle.encrypt( 101 | { name: "AES-GCM", iv }, 102 | cryptoKey, 103 | plaintext 104 | ); 105 | const transaction = this.db.transaction(this.name, "readwrite"); 106 | const record: RecordKeyPair = { did, ciphertext, iv }; 107 | await transaction.store.put(record); 108 | } 109 | 110 | async get(did: string, cryptoKey: CryptoKey): Promise { 111 | const transaction = this.db.transaction(this.name, "readonly"); 112 | const record: RecordKeyPair | undefined = await transaction.store.get(did); 113 | if (!record) return; 114 | try { 115 | const plaintext = await window.crypto.subtle.decrypt( 116 | { name: "AES-GCM", iv: record.iv }, 117 | cryptoKey, 118 | record.ciphertext 119 | ); 120 | const exported = JSON.parse(new TextDecoder().decode(plaintext)); 121 | const key = await Ed25519VerificationKey2020.from(exported); 122 | return key; 123 | } catch { 124 | return undefined; 125 | } 126 | } 127 | 128 | async getDids(): Promise { 129 | const transaction = this.db.transaction(this.name, "readonly"); 130 | return (await transaction.store.getAllKeys()) as string[]; 131 | } 132 | } 133 | 134 | type RecordIdName = IdName; 135 | 136 | class StoreIdName { 137 | static DEFAULT_NAME = "IdName"; 138 | 139 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreIdName.DEFAULT_NAME) {} 140 | 141 | static create(db: IDBPDatabase, name: string = StoreIdName.DEFAULT_NAME) { 142 | db.createObjectStore(name, { keyPath: "id" }).createIndex("name", "name"); 143 | return new StoreIdName(db, name); 144 | } 145 | 146 | async clear() { 147 | await this.db.transaction(this.name, "readwrite").store.clear(); 148 | } 149 | 150 | async put(idName: IdName) { 151 | const transaction = this.db.transaction(this.name, "readwrite"); 152 | await transaction.store.put(idName); 153 | } 154 | 155 | async putIfNewer(idName: IdName) { 156 | const transaction = this.db.transaction(this.name, "readwrite"); 157 | const current: RecordIdName | undefined = await transaction.store.get(idName.id); 158 | if (current && current.timestamp >= idName.timestamp) return; 159 | await transaction.store.put(idName); 160 | } 161 | 162 | async updateIfNewer(idName: IdName) { 163 | const transaction = this.db.transaction(this.name, "readwrite"); 164 | const current: RecordIdName | undefined = await transaction.store.get(idName.id); 165 | if (!current) return; 166 | if (current.timestamp >= idName.timestamp) return; 167 | await transaction.store.put(idName); 168 | } 169 | 170 | async get(id: string): Promise { 171 | const transaction = this.db.transaction(this.name, "readonly"); 172 | const record: RecordIdName | undefined = await transaction.store.get(id); 173 | return record; 174 | } 175 | 176 | async getAll(): Promise { 177 | const transaction = this.db.transaction(this.name, "readonly"); 178 | return await transaction.store.getAll(); 179 | } 180 | } 181 | 182 | interface RecordServer { 183 | info: ServerInfo; 184 | lastListenTimestamp: number; 185 | } 186 | 187 | class StoreServer { 188 | static DEFAULT_NAME = "Server"; 189 | 190 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreServer.DEFAULT_NAME) {} 191 | 192 | static create(db: IDBPDatabase, name: string = StoreServer.DEFAULT_NAME) { 193 | db.createObjectStore(name, { keyPath: "info.url" }).createIndex( 194 | "lastListenTimestamp", 195 | "lastListenTimestamp" 196 | ); 197 | return new StoreServer(db, name); 198 | } 199 | 200 | async clear() { 201 | await this.db.transaction(this.name, "readwrite").store.clear(); 202 | } 203 | 204 | async update(record: RecordServer) { 205 | const transaction = this.db.transaction(this.name, "readwrite"); 206 | const prev: RecordServer | undefined = await transaction.store.get(record.info.url); 207 | await transaction.store.put({ ...prev, ...record }); 208 | } 209 | 210 | async getByLastListen(count?: number): Promise { 211 | const transaction = this.db.transaction(this.name, "readonly"); 212 | let serversInfo: ServerInfo[] = []; 213 | for await (const cursor of transaction.store 214 | .index("lastListenTimestamp") 215 | .iterate(null, "prev")) { 216 | const record: RecordServer = cursor.value; 217 | serversInfo.push(record.info); 218 | if (count && serversInfo.length >= count) break; 219 | } 220 | return serversInfo; 221 | } 222 | } 223 | 224 | interface RecordFollow { 225 | id: string; 226 | } 227 | 228 | class StoreFollow { 229 | static DEFAULT_NAME = "Follow"; 230 | 231 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreFollow.DEFAULT_NAME) {} 232 | 233 | static create(db: IDBPDatabase, name: string = StoreFollow.DEFAULT_NAME) { 234 | db.createObjectStore(name, { keyPath: "id" }); 235 | return new StoreFollow(db, name); 236 | } 237 | 238 | async clear() { 239 | await this.db.transaction(this.name, "readwrite").store.clear(); 240 | } 241 | 242 | async put(id: string) { 243 | const transaction = this.db.transaction(this.name, "readwrite"); 244 | const record: RecordFollow = { id }; 245 | await transaction.store.put(record); 246 | } 247 | 248 | async delete(id: string): Promise { 249 | const transaction = this.db.transaction(this.name, "readwrite"); 250 | await transaction.store.delete(id); 251 | } 252 | 253 | async get(url: string): Promise { 254 | const transaction = this.db.transaction(this.name, "readonly"); 255 | return await transaction.store.get(url); 256 | } 257 | 258 | async getAll(): Promise { 259 | const transaction = this.db.transaction(this.name, "readonly"); 260 | return (await transaction.store.getAll()).map((x) => x.id); 261 | } 262 | } 263 | 264 | interface RecordMessageId { 265 | id: string; 266 | idx: number; 267 | } 268 | 269 | interface PageOut { 270 | ids: string[]; 271 | nextStartIdx?: number; 272 | } 273 | 274 | class StoreMessage { 275 | static DEFAULT_NAME = "Message"; 276 | 277 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreMessage.DEFAULT_NAME) {} 278 | 279 | static create(db: IDBPDatabase, name: string = StoreMessage.DEFAULT_NAME) { 280 | db.createObjectStore(name, { keyPath: "idx", autoIncrement: true }).createIndex("id", "id"); 281 | return new StoreMessage(db, name); 282 | } 283 | 284 | async clear() { 285 | await this.db.transaction(this.name, "readwrite").store.clear(); 286 | } 287 | 288 | async delete(id: string): Promise { 289 | const transaction = this.db.transaction(this.name, "readwrite"); 290 | const key = await transaction.store.index("id").getKey(id); 291 | if (key == null) return; 292 | await transaction.store.delete(key); 293 | } 294 | 295 | async put(id: string) { 296 | const transaction = this.db.transaction(this.name, "readwrite"); 297 | await transaction.store.put({ id }); 298 | } 299 | 300 | async getPage(startIdx?: number, pageSize: number = 32): Promise { 301 | const transaction = this.db.transaction(this.name, "readwrite"); 302 | let query = startIdx != null ? IDBKeyRange.upperBound(startIdx, false) : undefined; 303 | let nextStartIdx: number | undefined = undefined; 304 | const ids: string[] = []; 305 | for await (const cursor of transaction.store.iterate(query, "prevunique")) { 306 | const record: RecordMessageId = cursor.value; 307 | ids.push(record.id); 308 | nextStartIdx = nextStartIdx ? Math.min(record.idx, nextStartIdx) : record.idx; 309 | if (ids.length >= pageSize) break; 310 | } 311 | nextStartIdx = nextStartIdx != null && nextStartIdx > 0 ? nextStartIdx - 1 : undefined; 312 | return { ids, nextStartIdx }; 313 | } 314 | } 315 | 316 | interface RecordDocument { 317 | document: Model.WithId; 318 | } 319 | 320 | class StoreDocument { 321 | static DEFAULT_NAME = "Document"; 322 | 323 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreDocument.DEFAULT_NAME) {} 324 | 325 | static create(db: IDBPDatabase, name: string = StoreDocument.DEFAULT_NAME) { 326 | db.createObjectStore(name, { keyPath: "document.id" }); 327 | return new StoreDocument(db, name); 328 | } 329 | 330 | async clear() { 331 | await this.db.transaction(this.name, "readwrite").store.clear(); 332 | } 333 | 334 | async get(id: string): Promise { 335 | const transaction = this.db.transaction(this.name, "readonly"); 336 | const record: RecordDocument | undefined = await transaction.store.get(id); 337 | return !!record ? record.document : undefined; 338 | } 339 | 340 | async delete(id: string): Promise { 341 | const transaction = this.db.transaction(this.name, "readwrite"); 342 | await transaction.store.delete(id); 343 | } 344 | 345 | async put(document: Model.WithId) { 346 | const transaction = this.db.transaction(this.name, "readwrite"); 347 | const record: RecordDocument = { document }; 348 | await transaction.store.put(record); 349 | } 350 | } 351 | 352 | interface RecordViewMessage { 353 | message: Model.Message; 354 | objectId: string; 355 | } 356 | 357 | /** 358 | * Stores a single view message for any object ID. 359 | */ 360 | class StoreViewMessage { 361 | static DEFAULT_NAME = "ViewMessage"; 362 | 363 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreViewMessage.DEFAULT_NAME) {} 364 | 365 | static create(db: IDBPDatabase, name: string = StoreViewMessage.DEFAULT_NAME) { 366 | db.createObjectStore(name, { keyPath: "objectId" }); 367 | return new StoreViewMessage(db, name); 368 | } 369 | 370 | async clear() { 371 | await this.db.transaction(this.name, "readwrite").store.clear(); 372 | } 373 | 374 | async get(objectId: string): Promise { 375 | const transaction = this.db.transaction(this.name, "readonly"); 376 | const record: RecordViewMessage | undefined = await transaction.store.get(objectId); 377 | return record?.message; 378 | } 379 | 380 | async put(message: Model.Message) { 381 | const transaction = this.db.transaction(this.name, "readwrite"); 382 | const record: RecordViewMessage = { message, objectId: message.object[0] }; 383 | await transaction.store.put(record); 384 | } 385 | } 386 | 387 | interface RecordMessageDocument { 388 | jointId: string; 389 | messageId: string; 390 | documentId: string; 391 | } 392 | 393 | /** 394 | * Stores a single view message for any object ID. 395 | */ 396 | class StoreMessageDocument { 397 | static DEFAULT_NAME = "MessageDocument"; 398 | 399 | constructor( 400 | readonly db: IDBPDatabase, 401 | readonly name: string = StoreMessageDocument.DEFAULT_NAME 402 | ) {} 403 | 404 | static create(db: IDBPDatabase, name: string = StoreMessageDocument.DEFAULT_NAME) { 405 | const store = db.createObjectStore(name, { keyPath: "jointId" }); 406 | store.createIndex("documentId", "documentId"); 407 | store.createIndex("messageId", "messageId"); 408 | return new StoreMessageDocument(db, name); 409 | } 410 | 411 | async clear() { 412 | await this.db.transaction(this.name, "readwrite").store.clear(); 413 | } 414 | 415 | async hasMessageWithDocument(documentId: string): Promise { 416 | const transaction = this.db.transaction(this.name, "readonly"); 417 | return (await transaction.store.index("documentId").count(documentId)) > 0; 418 | } 419 | 420 | async getDocumentsForMessage(messageId: string): Promise { 421 | const transaction = this.db.transaction(this.name, "readonly"); 422 | return (await transaction.store.index("messageId").getAll(messageId)).map((x) => x.documentId); 423 | } 424 | 425 | async deleteForMessage(messageId: string) { 426 | const transaction = this.db.transaction(this.name, "readwrite"); 427 | const keys = await transaction.store.index("messageId").getAllKeys(messageId); 428 | for (const key of keys) transaction.store.delete(key); 429 | } 430 | 431 | async delete(messageId: string, documentId: string) { 432 | const transaction = this.db.transaction(this.name, "readwrite"); 433 | const jointId = JSON.stringify([messageId, documentId]); 434 | await transaction.store.delete(jointId); 435 | } 436 | 437 | async put(messageId: string, documentId: string) { 438 | const transaction = this.db.transaction(this.name, "readwrite"); 439 | const jointId = JSON.stringify([messageId, documentId]); 440 | const record: RecordMessageDocument = { jointId, messageId, documentId }; 441 | await transaction.store.put(record); 442 | } 443 | } 444 | 445 | /** 446 | * Stores unique document IDs. 447 | */ 448 | class StoreDocumentId { 449 | static DEFAULT_NAME = "DocumentId"; 450 | 451 | constructor(readonly db: IDBPDatabase, readonly name: string = StoreDocumentId.DEFAULT_NAME) {} 452 | 453 | static create(db: IDBPDatabase, name: string = StoreDocumentId.DEFAULT_NAME) { 454 | // store plain IDs, specify ID as key when putting, so no key path here 455 | db.createObjectStore(name); 456 | return new StoreDocumentId(db, name); 457 | } 458 | 459 | async clear() { 460 | await this.db.transaction(this.name, "readwrite").store.clear(); 461 | } 462 | 463 | async hasId(id: string): Promise { 464 | const transaction = this.db.transaction(this.name, "readonly"); 465 | return (await transaction.store.count(id)) > 0; 466 | } 467 | 468 | async delete(id: string) { 469 | const transaction = this.db.transaction(this.name, "readwrite"); 470 | await transaction.store.delete(id); 471 | } 472 | 473 | async put(id: string) { 474 | const transaction = this.db.transaction(this.name, "readwrite"); 475 | // use the ID as the key 476 | await transaction.store.put(id, id); 477 | } 478 | } 479 | 480 | export class DbDevice { 481 | static DEFAULT_NAME = "Device"; 482 | 483 | constructor( 484 | readonly db: IDBPDatabase, 485 | readonly idSalt: StoreIdSalt, 486 | readonly keyPair: StoreKeyPair, 487 | readonly idName: StoreIdName 488 | ) {} 489 | 490 | static async new(name: string = DbDevice.DEFAULT_NAME): Promise { 491 | let storeIdSalt: StoreIdSalt | undefined = undefined; 492 | let storeKeyPair: StoreKeyPair | undefined = undefined; 493 | let storeIdName: StoreIdName | undefined = undefined; 494 | const db = await openDB(name, DB_VERSION, { 495 | upgrade: (db) => { 496 | storeIdSalt = StoreIdSalt.create(db); 497 | storeKeyPair = StoreKeyPair.create(db); 498 | storeIdName = StoreIdName.create(db); 499 | }, 500 | }); 501 | storeIdSalt = storeIdSalt ? storeIdSalt : new StoreIdSalt(db); 502 | storeKeyPair = storeKeyPair ? storeKeyPair : new StoreKeyPair(db); 503 | storeIdName = storeIdName ? storeIdName : new StoreIdName(db); 504 | return new DbDevice(db, storeIdSalt, storeKeyPair, storeIdName); 505 | } 506 | 507 | async clear() { 508 | await this.idSalt.clear(); 509 | await this.keyPair.clear(); 510 | await this.idName.clear(); 511 | } 512 | } 513 | 514 | export class DbPeer { 515 | static DEFAULT_NAME = "Peer"; 516 | 517 | constructor( 518 | readonly db: IDBPDatabase, 519 | readonly server: StoreServer, 520 | readonly follow: StoreFollow, 521 | readonly message: StoreMessage, 522 | readonly document: StoreDocument, 523 | readonly messageDocument: StoreMessageDocument, 524 | readonly viewMessage: StoreViewMessage, 525 | readonly deleted: StoreDocumentId, 526 | readonly idName: StoreIdName 527 | ) {} 528 | 529 | static async new(name: string = DbPeer.DEFAULT_NAME): Promise { 530 | let storeServer: StoreServer | undefined = undefined; 531 | let storeFollow: StoreFollow | undefined = undefined; 532 | let storeMessage: StoreMessage | undefined = undefined; 533 | let storeDocument: StoreDocument | undefined = undefined; 534 | let storeMessageDocument: StoreMessageDocument | undefined = undefined; 535 | let storeViewMessage: StoreViewMessage | undefined = undefined; 536 | let storeDeleted: StoreDocumentId | undefined = undefined; 537 | let storeIdName: StoreIdName | undefined = undefined; 538 | const db = await openDB(name, DB_VERSION, { 539 | upgrade: (db) => { 540 | storeServer = StoreServer.create(db); 541 | storeFollow = StoreFollow.create(db); 542 | storeMessage = StoreMessage.create(db); 543 | storeDocument = StoreDocument.create(db); 544 | storeMessageDocument = StoreMessageDocument.create(db); 545 | storeViewMessage = StoreViewMessage.create(db); 546 | storeDeleted = StoreDocumentId.create(db, "Deleted"); 547 | storeIdName = StoreIdName.create(db); 548 | }, 549 | }); 550 | storeServer = storeServer ? storeServer : new StoreServer(db); 551 | storeFollow = storeFollow ? storeFollow : new StoreFollow(db); 552 | storeMessage = storeMessage ? storeMessage : new StoreMessage(db); 553 | storeDocument = storeDocument ? storeDocument : new StoreDocument(db); 554 | storeMessageDocument = storeMessageDocument 555 | ? storeMessageDocument 556 | : new StoreMessageDocument(db); 557 | storeViewMessage = storeViewMessage ? storeViewMessage : new StoreViewMessage(db); 558 | storeDeleted = storeDeleted ? storeDeleted : new StoreDocumentId(db, "Deleted"); 559 | storeIdName = storeIdName ? storeIdName : new StoreIdName(db); 560 | return new DbPeer( 561 | db, 562 | storeServer, 563 | storeFollow, 564 | storeMessage, 565 | storeDocument, 566 | storeMessageDocument, 567 | storeViewMessage, 568 | storeDeleted, 569 | storeIdName 570 | ); 571 | } 572 | 573 | async clear() { 574 | await this.server.clear(); 575 | await this.follow.clear(); 576 | await this.message.clear(); 577 | await this.document.clear(); 578 | await this.messageDocument.clear(); 579 | await this.viewMessage.clear(); 580 | await this.deleted.clear(); 581 | await this.idName.clear(); 582 | } 583 | } 584 | -------------------------------------------------------------------------------- /test/chatternet.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChatterNet, DidKey, MessageIter } from "../src/index.js"; 2 | import { didFromActorId } from "../src/model/actor.js"; 3 | import type { Actor, Message } from "../src/model/index.js"; 4 | import type { ServerInfo } from "../src/storage.js"; 5 | import * as Storage from "../src/storage.js"; 6 | import * as assert from "assert"; 7 | import "fake-indexeddb/auto"; 8 | import { get } from "lodash-es"; 9 | import "mock-local-storage"; 10 | 11 | // @ts-ignore 12 | global.window = { 13 | crypto: globalThis.crypto, 14 | localStorage: global.localStorage, 15 | }; 16 | 17 | function actorToServerInfo(actor: Actor): ServerInfo { 18 | const did = didFromActorId(actor.id); 19 | const actorUrl = actor.url; 20 | if (did == null) throw Error("actor ID is invalid"); 21 | if (actorUrl == null) throw Error("actor has no URL"); 22 | if (!actorUrl.endsWith(`/${actor.id}`)) throw Error("actor URL is not a path to its ID"); 23 | const url = actorUrl.slice(0, -actor.id.length - 1); 24 | return { url, did }; 25 | } 26 | 27 | async function clearDbs() { 28 | await (await Storage.DbDevice.new()).clear(); 29 | await (await Storage.DbPeer.new()).clear(); 30 | } 31 | 32 | describe("chatter net", () => { 33 | const defaultServersActor: Actor[] = process.env.CHATTERNET_TEST_SERVER 34 | ? [JSON.parse(process.env.CHATTERNET_TEST_SERVER)] 35 | : []; 36 | const defaultServers: ServerInfo[] = defaultServersActor.map(actorToServerInfo); 37 | 38 | it("builds from new account", async () => { 39 | await clearDbs(); 40 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 41 | await ChatterNet.new(did, "abc", defaultServers); 42 | }); 43 | 44 | it("lists accounts and names", async () => { 45 | await clearDbs(); 46 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 47 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "some name 2", "abc"); 48 | const did3 = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 49 | const accounts = await ChatterNet.getAccountNames(); 50 | const didToName = new Map(accounts.map(({ id, name }) => [id, name])); 51 | assert.equal(didToName.size, 3); 52 | assert.equal(didToName.get(did1), "some name"); 53 | assert.equal(didToName.get(did2), "some name 2"); 54 | assert.equal(didToName.get(did3), "some name"); 55 | }); 56 | 57 | it("doesnt build for wrong password", async () => { 58 | await clearDbs(); 59 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 60 | assert.rejects(() => ChatterNet.new(did, "abcd", [])); 61 | }); 62 | 63 | it("changes name and builds with new name", async () => { 64 | await clearDbs(); 65 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 66 | { 67 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 68 | chatterNet.changeName("some other name"); 69 | assert.equal(chatterNet.getLocalName(), "some other name"); 70 | } 71 | { 72 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 73 | assert.equal(chatterNet.getLocalName(), "some other name"); 74 | } 75 | }); 76 | 77 | it("changes password and builds with new password", async () => { 78 | await clearDbs(); 79 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 80 | { 81 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 82 | await chatterNet.changePassword("abc", "abcd"); 83 | } 84 | { 85 | await ChatterNet.new(did, "abcd", defaultServers); 86 | } 87 | }); 88 | 89 | it("doesnt change password with wrong password", async () => { 90 | await clearDbs(); 91 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 92 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 93 | assert.rejects(() => chatterNet.changePassword("abcd", "abcd")); 94 | }); 95 | 96 | it("builds a message", async () => { 97 | await clearDbs(); 98 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 99 | const followers = ChatterNet.followersFromId(ChatterNet.actorFromDid(did)); 100 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 101 | const message = await chatterNet.newMessage(["id:a", "id:b"], "View", [followers]); 102 | assert.deepEqual(message.object, ["id:a", "id:b"]); 103 | assert.equal(message.type, "View"); 104 | }); 105 | 106 | it("builds a note", async () => { 107 | await clearDbs(); 108 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 109 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 110 | const { documents } = await chatterNet.newNote("abcd", await chatterNet.toSelf()); 111 | assert.equal(documents.length, 2); 112 | assert.equal(get(documents, "0.type"), "Note"); 113 | assert.equal(get(documents, "0.content"), "abcd"); 114 | assert.equal(get(documents, "1.type"), "Person"); 115 | assert.equal(get(documents, "1.name"), "some name"); 116 | }); 117 | 118 | it("builds a delete message", async () => { 119 | await clearDbs(); 120 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 121 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 122 | const messageObjectDoc = await chatterNet1.newNote("abcd", await chatterNet1.toSelf()); 123 | // if local has message, it can be deleted 124 | await chatterNet1.storeMessageDocuments(messageObjectDoc); 125 | const deleteMessage = await chatterNet1.newDelete(messageObjectDoc.message.id); 126 | assert.ok(deleteMessage); 127 | assert.equal(deleteMessage.type, "Delete"); 128 | assert.deepEqual(deleteMessage.object, [messageObjectDoc.message.id]); 129 | }); 130 | 131 | it("follows unfollows and lists follows", async () => { 132 | await clearDbs(); 133 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 134 | { 135 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 136 | const { message } = await chatterNet.newFollow({ id: "id:a", name: "name a", timestamp: 10 }); 137 | assert.equal(message.type, "Add"); 138 | assert.deepEqual(message.object, ["id:a"]); 139 | assert.deepEqual(message.target, [`${did}/actor/following`]); 140 | } 141 | { 142 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 143 | await chatterNet.newFollow({ id: "id:b", name: "name b", timestamp: 10 }); 144 | } 145 | { 146 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 147 | const { message } = await chatterNet.newUnfollow("id:a"); 148 | assert.equal(message.type, "Remove"); 149 | assert.deepEqual(message.object, ["id:a"]); 150 | assert.deepEqual(message.target, [`${did}/actor/following`]); 151 | } 152 | { 153 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 154 | const { message } = await chatterNet.buildSetFollows(); 155 | assert.deepEqual(new Set(message.object), new Set([`${did}/actor`, "id:b"])); 156 | } 157 | }); 158 | 159 | it("builds a listen server", async () => { 160 | await clearDbs(); 161 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 162 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 163 | const { message } = await chatterNet.newListen("did:example:a"); 164 | assert.equal(message.type, "Listen"); 165 | assert.deepEqual(message.object, ["did:example:a/actor"]); 166 | }); 167 | 168 | it("builds a view message", async () => { 169 | await clearDbs(); 170 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 171 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 172 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 173 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 174 | const followers1 = ChatterNet.followersFromId(ChatterNet.actorFromDid(did1)); 175 | const origin = await chatterNet1.newMessage(["id:a"], "Create", [followers1]); 176 | const message = await chatterNet2.getOrNewViewMessage(origin); 177 | assert.ok(message); 178 | assert.equal(message.type, "View"); 179 | assert.deepEqual(message.origin, [origin.id]); 180 | assert.deepEqual(message.object, ["id:a"]); 181 | }); 182 | 183 | it("doest build a view of a view", async () => { 184 | await clearDbs(); 185 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 186 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 187 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 188 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 189 | const followers1 = ChatterNet.followersFromId(ChatterNet.actorFromDid(did1)); 190 | const origin = await chatterNet1.newMessage(["id:a"], "View", [followers1]); 191 | const message = await chatterNet2.getOrNewViewMessage(origin); 192 | assert.ok(!message); 193 | }); 194 | 195 | it("doest build a view of a message by self", async () => { 196 | await clearDbs(); 197 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 198 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 199 | const followers1 = ChatterNet.followersFromId(ChatterNet.actorFromDid(did1)); 200 | const origin = await chatterNet1.newMessage(["id:a"], "Create", [followers1]); 201 | const message = await chatterNet1.getOrNewViewMessage(origin); 202 | assert.ok(!message); 203 | }); 204 | 205 | it("re-uses a view message", async () => { 206 | await clearDbs(); 207 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 208 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 209 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 210 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 211 | const followers1 = ChatterNet.followersFromId(ChatterNet.actorFromDid(did1)); 212 | const origin = await chatterNet1.newMessage(["id:a"], "Create", [followers1]); 213 | const message1 = await chatterNet2.getOrNewViewMessage(origin); 214 | const message2 = await chatterNet2.getOrNewViewMessage(origin); 215 | assert.ok(message1); 216 | assert.ok(message2); 217 | assert.deepEqual(message1, message2); 218 | }); 219 | 220 | it("builds actor", async () => { 221 | await clearDbs(); 222 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 223 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 224 | const { message, documents } = await chatterNet.buildActor(); 225 | assert.equal(message.type, "Create"); 226 | assert.equal(message.object, ChatterNet.actorFromDid(did)); 227 | assert.equal(documents.length, 1); 228 | assert.equal(documents[0].id, ChatterNet.actorFromDid(did)); 229 | }); 230 | 231 | it("posts and gets actor with server", async () => { 232 | if (defaultServers.length <= 0) return; 233 | await clearDbs(); 234 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 235 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 236 | await chatterNet.postMessageDocuments(await chatterNet.buildActor()); 237 | const actor = await chatterNet.getActor(ChatterNet.actorFromDid(did)); 238 | assert.ok(actor); 239 | assert.equal(actor.id, ChatterNet.actorFromDid(did)); 240 | }); 241 | 242 | it("posts and gets actor with local", async () => { 243 | await clearDbs(); 244 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 245 | const chatterNet = await ChatterNet.new(did, "abc", []); 246 | await chatterNet.storeMessageDocuments(await chatterNet.buildActor()); 247 | const actor = await chatterNet.getActor(ChatterNet.actorFromDid(did)); 248 | assert.ok(actor); 249 | assert.equal(actor.id, ChatterNet.actorFromDid(did)); 250 | }); 251 | 252 | async function listMessages(messageIter: MessageIter): Promise { 253 | const messages: Message[] = []; 254 | for await (const message of messageIter.messages()) if (!!message) messages.push(message); 255 | return messages; 256 | } 257 | 258 | it("posts and gets messages with server", async () => { 259 | if (defaultServers.length <= 0) return; 260 | 261 | await clearDbs(); 262 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 263 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 264 | const did3 = await ChatterNet.newAccount(await DidKey.newKey(), "name3", "abc"); 265 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 266 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 267 | const chatterNet3 = await ChatterNet.new(did3, "abc", defaultServers); 268 | 269 | // did1 posts 270 | const note = await chatterNet1.newNote("Hi!", await chatterNet1.toSelf()); 271 | await chatterNet1.postMessageDocuments(note); 272 | // can't get from local 273 | assert.ok(!(await chatterNet1.getDocument(note.message.id, true))); 274 | // gets object 275 | assert.equal((await chatterNet1.getDocument(note.message.id))?.id, note.message.id); 276 | assert.equal((await chatterNet1.getDocument(note.documents[0].id))?.id, note.documents[0].id); 277 | // iterates own message 278 | const messages1 = await listMessages(await chatterNet1.buildMessageIter()); 279 | assert.ok(new Set(messages1.map((x) => x.id)).has(note.message.id)); 280 | 281 | // did2 follows did1 282 | await chatterNet2.postMessageDocuments( 283 | await chatterNet2.newFollow({ 284 | id: ChatterNet.actorFromDid(did1), 285 | name: "name", 286 | timestamp: 10, 287 | }) 288 | ); 289 | // iterates message 290 | const messages2 = await listMessages(await chatterNet2.buildMessageIter()); 291 | assert.ok(new Set(messages2.map((x) => x.id)).has(note.message.id)); 292 | // views message 293 | const viewMessage = await chatterNet2.getOrNewViewMessage(note.message); 294 | assert.ok(viewMessage); 295 | await chatterNet2.postMessageDocuments({ message: viewMessage, documents: [] }); 296 | 297 | // did3 follows did2 298 | await chatterNet3.postMessageDocuments( 299 | await chatterNet3.newFollow({ 300 | id: ChatterNet.actorFromDid(did2), 301 | name: "name", 302 | timestamp: 10, 303 | }) 304 | ); 305 | // did3 see view 306 | const messages3 = await listMessages(await chatterNet2.buildMessageIter()); 307 | assert.ok(new Set(messages3.map((x) => x.id)).has(viewMessage.id)); 308 | }); 309 | 310 | it("deletes messages and documents with server", async () => { 311 | if (defaultServers.length <= 0) return; 312 | 313 | await clearDbs(); 314 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 315 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 316 | 317 | // did1 posts 318 | const note = await chatterNet1.newNote("Hi!", await chatterNet1.toSelf()); 319 | await chatterNet1.postMessageDocuments(note); 320 | // gets object 321 | assert.equal((await chatterNet1.getDocument(note.message.id))?.id, note.message.id); 322 | assert.equal((await chatterNet1.getDocument(note.documents[0].id))?.id, note.documents[0].id); 323 | 324 | const deleteDocument = await chatterNet1.newDelete(note.documents[0].id); 325 | const deleteMessage = await chatterNet1.newDelete(note.message.id); 326 | await chatterNet1.postMessageDocuments({ message: deleteDocument, documents: [] }); 327 | await chatterNet1.postMessageDocuments({ message: deleteMessage, documents: [] }); 328 | }); 329 | 330 | it("gets messages from actor with server", async () => { 331 | if (defaultServers.length <= 0) return; 332 | await clearDbs(); 333 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 334 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 335 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 336 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 337 | // did1 posts 338 | const note = await chatterNet1.newNote("Hi!", await chatterNet1.toSelf()); 339 | await chatterNet1.postMessageDocuments(note); 340 | // iterates message 341 | const messages = await listMessages(await chatterNet2.buildMessageIterFrom(`${did1}/actor`)); 342 | assert.ok(new Set(messages.map((x) => x.id)).has(note.message.id)); 343 | }); 344 | 345 | it("gets messages with audience with server", async () => { 346 | if (defaultServers.length <= 0) return; 347 | await clearDbs(); 348 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 349 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 350 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 351 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 352 | // did2 follows did1 353 | await chatterNet2.postMessageDocuments( 354 | await chatterNet2.newFollow({ 355 | id: ChatterNet.actorFromDid(did1), 356 | name: "name", 357 | timestamp: 10, 358 | }) 359 | ); 360 | // did1 posts 361 | const tag = await chatterNet1.buildTag("tag"); 362 | const note = await chatterNet1.newNote("Hi!", [tag]); 363 | await chatterNet1.postMessageDocuments(note); 364 | // iterates message 365 | const messages = await listMessages( 366 | await chatterNet2.buildMessageIterWith([`${tag.id}/followers`]) 367 | ); 368 | assert.ok(new Set(messages.map((x) => x.id)).has(note.message.id)); 369 | }); 370 | 371 | it("gets create message for object with server", async () => { 372 | if (defaultServers.length <= 0) return; 373 | await clearDbs(); 374 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 375 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 376 | // did1 posts 377 | const note = await chatterNet.newNote("Hi!", await chatterNet.toSelf()); 378 | await chatterNet.postMessageDocuments(note); 379 | const message = await chatterNet.getCreateMessageForDocument( 380 | note.documents[0].id, 381 | `${did}/actor` 382 | ); 383 | assert.equal(message?.id, note.message.id); 384 | }); 385 | 386 | it("doesnt post invalid message with server", async () => { 387 | if (defaultServers.length <= 0) return; 388 | await clearDbs(); 389 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 390 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 391 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 392 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 393 | const note = await chatterNet1.newNote("hello", await chatterNet1.toSelf()); 394 | // sent from wrong account 395 | assert.rejects(async () => await chatterNet2.postMessageDocuments(note)); 396 | }); 397 | 398 | it("doesnt post invalid document with server", async () => { 399 | if (defaultServers.length <= 0) return; 400 | await clearDbs(); 401 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 402 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 403 | const note = await chatterNet.newNote("hello", await chatterNet.toSelf()); 404 | // invalid document id 405 | note.documents[0].id = "urn:cid:a"; 406 | assert.rejects(async () => await chatterNet.postMessageDocuments(note)); 407 | }); 408 | 409 | it("posts and gets messages with local", async () => { 410 | await clearDbs(); 411 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 412 | const chatterNet1 = await ChatterNet.new(did1, "abc", []); 413 | // did1 posts 414 | const note = await chatterNet1.newNote("Hi!", await chatterNet1.toSelf()); 415 | await chatterNet1.storeMessageDocuments(note); 416 | // gets object 417 | assert.equal((await chatterNet1.getDocument(note.message.id))?.id, note.message.id); 418 | assert.equal((await chatterNet1.getDocument(note.documents[0].id))?.id, note.documents[0].id); 419 | // iterates own message 420 | const messages1 = await listMessages(await chatterNet1.buildMessageIter()); 421 | assert.ok(new Set(messages1.map((x) => x.id)).has(note.message.id)); 422 | }); 423 | 424 | it("unstores local message", async () => { 425 | await clearDbs(); 426 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 427 | const chatterNet1 = await ChatterNet.new(did1, "abc", []); 428 | // did1 posts 429 | const note = await chatterNet1.newNote("Hi!", await chatterNet1.toSelf()); 430 | await chatterNet1.storeMessageDocuments(note); 431 | // can retrieve message 432 | assert.equal((await chatterNet1.getDocument(note.message.id))?.id, note.message.id); 433 | assert.equal((await chatterNet1.getDocument(note.documents[0].id))?.id, note.documents[0].id); 434 | assert.equal((await listMessages(await chatterNet1.buildMessageIter())).length, 1); 435 | // message is not deleted 436 | assert.ok(!(await chatterNet1.isDeleted(note.message.id))); 437 | // removes message 438 | await chatterNet1.deleteLocalId(note.message.id); 439 | // message is deleted 440 | assert.ok(await chatterNet1.isDeleted(note.message.id)); 441 | // can no longer retrieve message object 442 | assert.ok(!(await chatterNet1.getDocument(note.message.id))); 443 | assert.ok(!(await chatterNet1.getDocument(note.documents[0].id))); 444 | // no longer iterates message 445 | assert.equal((await listMessages(await chatterNet1.buildMessageIter())).length, 0); 446 | }); 447 | 448 | it("builds message affinity", async () => { 449 | await clearDbs(); 450 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 451 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 452 | const did3 = await ChatterNet.newAccount(await DidKey.newKey(), "name3", "abc"); 453 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 454 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 455 | const chatterNet3 = await ChatterNet.new(did3, "abc", defaultServers); 456 | 457 | // did1 posts 458 | const { message: blankMessage } = await chatterNet1.newNote("Hi!", []); 459 | const { message: noteMessage } = await chatterNet1.newNote("Hi!", await chatterNet1.toSelf()); 460 | assert.deepEqual(await chatterNet1.buildMessageAffinity(blankMessage), { 461 | fromContact: true, 462 | inAudience: false, 463 | }); 464 | assert.deepEqual(await chatterNet1.buildMessageAffinity(noteMessage), { 465 | fromContact: true, 466 | inAudience: true, 467 | }); 468 | 469 | // did2 follows did1 470 | await chatterNet2.newFollow({ id: ChatterNet.actorFromDid(did1), name: "name", timestamp: 10 }); 471 | assert.deepEqual(await chatterNet2.buildMessageAffinity(noteMessage), { 472 | fromContact: true, 473 | inAudience: true, 474 | }); 475 | 476 | // did3 follows did2 477 | await chatterNet3.newFollow({ id: ChatterNet.actorFromDid(did2), name: "name", timestamp: 10 }); 478 | assert.deepEqual(await chatterNet3.buildMessageAffinity(noteMessage), { 479 | fromContact: false, 480 | inAudience: false, 481 | }); 482 | }); 483 | 484 | it("iterates followers with server", async () => { 485 | if (defaultServers.length <= 0) return; 486 | 487 | await clearDbs(); 488 | const did1 = await ChatterNet.newAccount(await DidKey.newKey(), "name1", "abc"); 489 | const did2 = await ChatterNet.newAccount(await DidKey.newKey(), "name2", "abc"); 490 | const did3 = await ChatterNet.newAccount(await DidKey.newKey(), "name3", "abc"); 491 | const chatterNet1 = await ChatterNet.new(did1, "abc", defaultServers); 492 | const chatterNet2 = await ChatterNet.new(did2, "abc", defaultServers); 493 | const chatterNet3 = await ChatterNet.new(did3, "abc", defaultServers); 494 | const actorId1 = ChatterNet.actorFromDid(did1); 495 | 496 | // did2 follows did1 497 | await chatterNet2.postMessageDocuments( 498 | await chatterNet2.newFollow({ id: actorId1, name: "name", timestamp: 10 }) 499 | ); 500 | // did3 follows did1 501 | await chatterNet3.postMessageDocuments( 502 | await chatterNet3.newFollow({ id: actorId1, name: "name", timestamp: 10 }) 503 | ); 504 | 505 | // iterates followers 506 | const iter = chatterNet1.buildFollowersIter(); 507 | const followers: string[] = []; 508 | for await (const follower of iter.pageItems()) { 509 | followers.push(follower); 510 | } 511 | 512 | assert.deepEqual( 513 | new Set(followers), 514 | new Set([`${did3}/actor`, `${did2}/actor`, `${did1}/actor`]) 515 | ); 516 | }); 517 | 518 | it("builds a tag", async () => { 519 | await clearDbs(); 520 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "name", "abc"); 521 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 522 | const tag = await chatterNet.buildTag("abc"); 523 | assert.equal(tag.name, "abc"); 524 | }); 525 | 526 | it("gets local did", async () => { 527 | await clearDbs(); 528 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 529 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 530 | assert.equal(chatterNet.getLocalDid(), did); 531 | }); 532 | 533 | it("gets local name", async () => { 534 | await clearDbs(); 535 | const did = await ChatterNet.newAccount(await DidKey.newKey(), "some name", "abc"); 536 | const chatterNet = await ChatterNet.new(did, "abc", defaultServers); 537 | assert.equal(chatterNet.getLocalName(), "some name"); 538 | }); 539 | 540 | it("builds actor form did", async () => { 541 | assert.equal(ChatterNet.actorFromDid("did:example:a"), "did:example:a/actor"); 542 | }); 543 | 544 | it("builds followers from id", async () => { 545 | assert.equal(ChatterNet.followersFromId("id:a"), "id:a/followers"); 546 | }); 547 | }); 548 | -------------------------------------------------------------------------------- /src/chatternet.ts: -------------------------------------------------------------------------------- 1 | import * as DidKey from "./didkey.js"; 2 | import { MessageIter } from "./messageiter.js"; 3 | import { didFromActorId } from "./model/actor.js"; 4 | import type { Actor, Message, WithId } from "./model/index.js"; 5 | import * as Model from "./model/index.js"; 6 | import { PageIter } from "./pageiter.js"; 7 | import { Servers } from "./servers.js"; 8 | import type { Key } from "./signatures.js"; 9 | import * as Storage from "./storage.js"; 10 | import type { IdName } from "./storage.js"; 11 | import { getTimestamp } from "./utils.js"; 12 | 13 | interface Dbs { 14 | device: Storage.DbDevice; 15 | peer: Storage.DbPeer; 16 | } 17 | 18 | /** 19 | * A `Message` document and optionally any of the `Object` documents listed in 20 | * the message's `object` property. 21 | * 22 | * `Message` documents are used to send meta-data. The content is typically 23 | * stored in a separate `Object` document which is listed in the message's 24 | * `object` property. 25 | */ 26 | export interface MessageDocuments { 27 | message: Message; 28 | documents: WithId[]; 29 | } 30 | 31 | /** 32 | * Values used to determine a message's affinity to a user's inbox. 33 | */ 34 | export interface MessageAffinity { 35 | fromContact: boolean; 36 | inAudience: boolean; 37 | } 38 | 39 | /** 40 | * Chatter Net client. 41 | * 42 | * This object provides interfaces to access global Chatter Net state through 43 | * HTTP calls to servers, and local node state using `IndexedDB`. 44 | */ 45 | export class ChatterNet { 46 | private name: string; 47 | 48 | /** 49 | * Construct a new instance with specific state. 50 | * 51 | * See `ChatterNet.new` which will call this constructor with state 52 | * initialized for a given actor. 53 | * 54 | * @param name the user name of the local actor 55 | * @param key the full key of the local actor 56 | * @param dbs the local databases 57 | * @param servers information about servers to communicate with to exchange 58 | * global state 59 | */ 60 | constructor( 61 | name: string, 62 | private readonly key: Key, 63 | private readonly dbs: Dbs, 64 | private readonly servers: Servers 65 | ) { 66 | this.name = name; 67 | } 68 | 69 | /** 70 | * Create a new account and persist it in the local state. 71 | * 72 | * This is a local operation. The account will be known to servers only once 73 | * messages are sent to those severs by the account. 74 | * 75 | * The account is authenticated using its private key which is stored 76 | * locally. A malicious actor needs to gain access to the local storage 77 | * (usually by having access to the physical device) to steal the private key. 78 | * 79 | * Leaving the password blank means that anyone with access to the local 80 | * device can gain access to the private key. If the user believes their 81 | * local device and browser are secure, it is possible though not advisable 82 | * to use a blank password. 83 | * 84 | * @param key the full key of the account 85 | * @param name the user name to associate with the account 86 | * @param password the password used to encrypt the public key 87 | * @returns the account's DID 88 | */ 89 | static async newAccount(key: Key, name: string, password: string): Promise { 90 | const db = await Storage.DbDevice.new(); 91 | const did = DidKey.didFromKey(key); 92 | const salt = await db.idSalt.getPut(did); 93 | const cryptoKey = await Storage.cryptoKeyFromPassword(password, salt); 94 | await db.keyPair.put(key, cryptoKey); 95 | await db.idName.putIfNewer({ id: did, name, timestamp: getTimestamp() }); 96 | return did; 97 | } 98 | 99 | /** 100 | * List DIDs of accounts in the local store and associated user names. 101 | * 102 | * @returns list of IDs and names 103 | */ 104 | static async getAccountNames(): Promise { 105 | const db = await Storage.DbDevice.new(); 106 | const dids = await db.keyPair.getDids(); 107 | const idNames = []; 108 | for (const did of dids) { 109 | const idName = await db.idName.get(did); 110 | if (idName == null) continue; 111 | idNames.push(idName); 112 | } 113 | return idNames; 114 | } 115 | 116 | /** 117 | * List all known ID name pairs. 118 | * 119 | * @returns mapping of ID to name 120 | */ 121 | static async getIdToName(): Promise> { 122 | const db = await Storage.DbDevice.new(); 123 | return new Map( 124 | (await db.idName.getAll()).filter(({ name }) => !!name).map(({ id, name }) => [id, name!]) 125 | ); 126 | } 127 | 128 | /** 129 | * Clear all all local stores. 130 | */ 131 | static async clearDbs() { 132 | const dbDevice = await Storage.DbDevice.new(); 133 | const dids = await dbDevice.keyPair.getDids(); 134 | for (const did of dids) window.indexedDB.deleteDatabase(`Peer_${did}`); 135 | window.indexedDB.deleteDatabase(dbDevice.db.name); 136 | } 137 | 138 | /** 139 | * Build a new client for the given actor DID. 140 | * 141 | * @param did actor DID 142 | * @param password password used to encrypt the DID's key 143 | * @param defaultServers connect to these servers on top of as any known 144 | * to the local actor 145 | * @returns a new `ChatterNet` client instance. 146 | */ 147 | static async new( 148 | did: string, 149 | password: string, 150 | defaultServers: Storage.ServerInfo[] 151 | ): Promise { 152 | const device = await Storage.DbDevice.new(); 153 | 154 | // decrypt the key for this actor 155 | const salt = await device.idSalt.getPut(did); 156 | const cryptoKey = await Storage.cryptoKeyFromPassword(password, salt); 157 | const key = await device.keyPair.get(did, cryptoKey); 158 | if (!key) throw Error("DID, password combination is incorrect."); 159 | 160 | // find the last known user name for this actor 161 | const idNameSuffix = await device.idName.get(did); 162 | if (!idNameSuffix) throw Error("there is no name for the given DID"); 163 | const { name } = idNameSuffix; 164 | if (!name) throw Error("there is no name for the given DID"); 165 | 166 | // find the servers this actor should listen to 167 | const peer = await Storage.DbPeer.new(`Peer_${did}`); 168 | const peerServers = await peer.server.getByLastListen(); 169 | const servers = Servers.fromInfos([...peerServers, ...defaultServers]); 170 | const chatterNet = new ChatterNet(name, key, { device, peer }, servers); 171 | 172 | // tell the server about the user name 173 | const actorMessageDocuments = await chatterNet.buildActor(true); 174 | chatterNet.postMessageDocuments(actorMessageDocuments).catch(() => {}); 175 | // tell the server about the actor follows 176 | (async () => { 177 | await chatterNet.postMessageDocuments(await chatterNet.buildClearFollows()); 178 | await chatterNet.postMessageDocuments(await chatterNet.buildSetFollows()); 179 | })().catch(() => {}); 180 | 181 | return chatterNet; 182 | } 183 | 184 | /** 185 | * Change the user name. 186 | * 187 | * This is a local operation. 188 | * 189 | * @param name the new user name 190 | */ 191 | async changeName(name: string): Promise { 192 | this.name = name; 193 | await this.dbs.device.idName.put({ id: this.getLocalDid(), name, timestamp: getTimestamp() }); 194 | return await this.buildActor(); 195 | } 196 | 197 | /** 198 | * Update ID name if it is already in the local store and newer than the 199 | * existing entry. 200 | * 201 | * ID names are added when an ID is followed. This method can then be used 202 | * to keep the names up-to-date. 203 | * 204 | * @param idName the ID name to update 205 | */ 206 | async updateIdName(idName: IdName) { 207 | await this.dbs.device.idName.updateIfNewer(idName); 208 | } 209 | 210 | /** 211 | * Change the password and re-encrypt the actor key with the new password. 212 | * 213 | * @param oldPassword ensure the user knows the current password 214 | * @param newPassword the new password 215 | * @returns returns true if the password was changed 216 | */ 217 | async changePassword(oldPassword: string, newPassword: string) { 218 | const did = this.getLocalDid(); 219 | const salt = await this.dbs.device.idSalt.getPut(did); 220 | const oldCryptoKey = await Storage.cryptoKeyFromPassword(oldPassword, salt); 221 | const confirmKey = await this.dbs.device.keyPair.get(did, oldCryptoKey); 222 | if (confirmKey?.fingerprint() !== this.key.fingerprint()) 223 | throw Error("current password is incorrect"); 224 | const newCryptoKey = await Storage.cryptoKeyFromPassword(newPassword, salt); 225 | await this.dbs.device.keyPair.put(this.key, newCryptoKey); 226 | } 227 | 228 | /** 229 | * Build the message clearing all of the local actor's follows on the server 230 | * handling the message. 231 | * 232 | * This message has no audience and will not propagate. It will affect only 233 | * the state of the servers it is directly sent to. 234 | */ 235 | async buildClearFollows(): Promise { 236 | const did = this.getLocalDid(); 237 | const actorId = ChatterNet.actorFromDid(did); 238 | const target = [`${actorId}/following`]; 239 | const message = await Model.newMessage(did, target, "Delete", null, this.key); 240 | return { message, documents: [] }; 241 | } 242 | 243 | /** 244 | * Build the message setting all of the local actor's follows on the server 245 | * handling the message. 246 | * 247 | * This message has no audience and will not propagate. It will affect only 248 | * the state of the servers it is directly sent to. 249 | */ 250 | async buildSetFollows(): Promise { 251 | const did = this.getLocalDid(); 252 | const actorId = ChatterNet.actorFromDid(did); 253 | const ids = [...new Set([...(await this.dbs.peer.follow.getAll()), actorId])]; 254 | const target = [`${actorId}/following`]; 255 | const message = await Model.newMessage(did, ids, "Add", null, this.key, { target }); 256 | return { message, documents: [] }; 257 | } 258 | 259 | /** 260 | * Build the message describing the local actor. 261 | */ 262 | async buildActor(unaddressed: boolean = false): Promise { 263 | const actorId = ChatterNet.actorFromDid(this.getLocalDid()); 264 | const to = unaddressed ? [] : [ChatterNet.followersFromId(actorId)]; 265 | const actor = await Model.newActor(this.getLocalDid(), "Person", this.key, { 266 | name: this.getLocalName(), 267 | }); 268 | const message = await this.newMessage([actorId], "Create", to); 269 | return { message, documents: [actor] }; 270 | } 271 | 272 | /** 273 | * Calculate a message's affinity to the local user's inbox. 274 | * 275 | * A messages should land in a user's inbox only if it's actor is a contact 276 | * of the local actor, and if it is addressed to an audience the local actor 277 | * belongs to. 278 | * 279 | * A server could return a message to an actor which does not belong to that 280 | * actor's inbox because of out-of-date data or non-compliance. 281 | */ 282 | async buildMessageAffinity(message: Message): Promise { 283 | const localFollows = await this.dbs.peer.follow.getAll(); 284 | const localActor = ChatterNet.actorFromDid(this.getLocalDid()); 285 | const fromContact = message.actor === localActor || new Set(localFollows).has(message.actor); 286 | const localAudience = ChatterNet.followersFromId(localActor); 287 | const localAudiences = new Set(localFollows.map((x) => ChatterNet.followersFromId(x))); 288 | const audiences = Model.getAudiences(message); 289 | let inAudience = false; 290 | for (const audience of audiences) { 291 | if (localAudience !== audience && !localAudiences.has(audience)) continue; 292 | inAudience = true; 293 | break; 294 | } 295 | return { fromContact, inAudience }; 296 | } 297 | 298 | /** 299 | * Store a message and any of its provided documents to the local store. 300 | * 301 | * @param messageDocuments 302 | */ 303 | async storeMessageDocuments(messageDocuments: MessageDocuments) { 304 | await this.dbs.peer.message.put(messageDocuments.message.id); 305 | await this.dbs.peer.document.put(messageDocuments.message); 306 | for (const document of messageDocuments.documents) { 307 | await this.dbs.peer.document.put(document); 308 | await this.dbs.peer.messageDocument.put(messageDocuments.message.id, document.id); 309 | } 310 | } 311 | 312 | /** 313 | * Remove a message or document from the local store if present. 314 | * 315 | * @param id the document ID to remove 316 | */ 317 | async deleteLocalId(id: string, forceDeleteObjects: boolean = false): Promise { 318 | await this.dbs.peer.deleted.put(id); 319 | await this.dbs.peer.message.delete(id); 320 | await this.dbs.peer.document.delete(id); 321 | const documentsId = await this.dbs.peer.messageDocument.getDocumentsForMessage(id); 322 | this.dbs.peer.messageDocument.deleteForMessage(id); 323 | for (const documentId of documentsId) { 324 | if ( 325 | !forceDeleteObjects && 326 | (await this.dbs.peer.messageDocument.hasMessageWithDocument(documentId)) 327 | ) 328 | continue; 329 | this.dbs.peer.document.delete(documentId); 330 | } 331 | } 332 | 333 | /** 334 | * Check if a message ID is known to be deleted. 335 | * 336 | * @param messageId 337 | * @returns 338 | */ 339 | async isDeleted(id: string): Promise { 340 | return await this.dbs.peer.deleted.hasId(id); 341 | } 342 | 343 | /** 344 | * Post a message and any of its provided documents to the servers. 345 | * 346 | * @param messageDocuments the message and associated documents to post 347 | */ 348 | async postMessageDocuments(messageDocuments: MessageDocuments) { 349 | const did = didFromActorId(messageDocuments.message.actor); 350 | if (did == null) throw Error("message actor is invalid"); 351 | await this.servers.postMessage(messageDocuments.message, did); 352 | for (const objectDoc of messageDocuments.documents) await this.postDocument(objectDoc); 353 | } 354 | 355 | /** 356 | * Post a document. 357 | * 358 | * @param document the document 359 | */ 360 | async postDocument(document: WithId) { 361 | await this.servers.postDocument(document); 362 | } 363 | 364 | /** 365 | * Build a list containing just the local actor's document. Useful for 366 | * sending a new message addressed to the local actor's followers. 367 | * 368 | * @returns a list with the local actor's document 369 | */ 370 | async toSelf(): Promise { 371 | const actor = await Model.newActor(this.getLocalDid(), "Person", this.key, { 372 | name: this.getLocalName(), 373 | }); 374 | return [actor]; 375 | } 376 | 377 | /** 378 | * Build and signs a new message. 379 | * 380 | * This is a local operation. 381 | * 382 | * @param ids list of message objects IDs 383 | * @param to other followers collections to add to the audience 384 | * @returns the signed message 385 | */ 386 | async newMessage(ids: string[], type: string, to: string[]): Promise { 387 | const did = this.getLocalDid(); 388 | return await Model.newMessage(did, ids, type, null, this.key, { to }); 389 | } 390 | 391 | /** 392 | * Builds and signs a message indicating that another should be deleted. The 393 | * message to delete must be stored locally and be from the local actor. 394 | * 395 | * This is a local operation. 396 | * 397 | * Sends to followers of the local actor and followers of the object. 398 | * 399 | * Note that if the object is neither a message by the local actor, nor a 400 | * document attributed to the local actor, the resulting message will be 401 | * invalid and rejected by compliant servers. 402 | * 403 | * @param ids list of message objects IDs 404 | * @returns the signed message 405 | */ 406 | async newDelete(id: string): Promise { 407 | const did = this.getLocalDid(); 408 | const actorId = ChatterNet.actorFromDid(did); 409 | const actorFollowers = ChatterNet.followersFromId(actorId); 410 | const objectsFollowers = `${id}/followers`; 411 | const to = [actorFollowers, objectsFollowers]; 412 | return await this.newMessage([id], "Delete", to); 413 | } 414 | 415 | /** 416 | * Build a new note. 417 | * 418 | * This is a local operation. 419 | * 420 | * @param content the string content of the note 421 | * @param toDocuments the documents whose followers are the audience 422 | * @param mediaType mime type of the content 423 | * @param inReplyTo URI of message this is in reply to 424 | * @returns 425 | */ 426 | async newNote( 427 | content: string, 428 | toDocuments: Model.WithId[], 429 | inReplyTo?: string 430 | ): Promise { 431 | const did = this.getLocalDid(); 432 | const attributedTo = ChatterNet.actorFromDid(did); 433 | const note = await Model.newNoteMd1k(content, attributedTo, { inReplyTo }); 434 | const to = toDocuments.map((x) => ChatterNet.followersFromId(x.id)); 435 | const message = await this.newMessage([note.id], "Create", to); 436 | return { message, documents: [note, ...toDocuments] }; 437 | } 438 | 439 | /** 440 | * Build a new follow. 441 | * 442 | * This is a local operation. 443 | * 444 | * The resulting message will tell the network that the local actor is 445 | * following the given `id`. The server will add the local actor to the 446 | * `followers` collection of the given `id`. 447 | * 448 | * If the `id` is another actor, that actor will become a "contact" of the 449 | * local actor, meaning that the servers will route messages authored by `id` 450 | * to the local actor. 451 | * 452 | * This message has no audience and will not propagate. It will affect only 453 | * the state of the servers it is directly sent to. 454 | * 455 | * @param idName the information about the ID to follow 456 | * @returns the message and object to send 457 | */ 458 | async newFollow(idName: IdName): Promise { 459 | await this.dbs.peer.follow.put(idName.id); 460 | if (idName.name) await this.dbs.peer.idName.putIfNewer(idName); 461 | const did = this.getLocalDid(); 462 | const actorId = ChatterNet.actorFromDid(did); 463 | const target = [`${actorId}/following`]; 464 | const message = await Model.newMessage(did, [idName.id], "Add", null, this.key, { target }); 465 | return { message, documents: [] }; 466 | } 467 | 468 | /** 469 | * Build a new un-follow. 470 | * 471 | * This is a local operation. 472 | * 473 | * The resulting message will tell the network that the local actor is 474 | * no longer following the given `id`. The server will remove the local 475 | * actor from the `followers` collection of the given `id`. 476 | * 477 | * This message has no audience and will not propagate. It will affect only 478 | * the state of the servers it is directly sent to. 479 | * 480 | * @param id ID followed by the actor 481 | * @returns the message and object to send 482 | */ 483 | async newUnfollow(id: string): Promise { 484 | await this.dbs.peer.follow.delete(id); 485 | const did = this.getLocalDid(); 486 | const actorId = ChatterNet.actorFromDid(did); 487 | const target = [`${actorId}/following`]; 488 | const message = await Model.newMessage(did, [id], "Remove", null, this.key, { target }); 489 | return { message, documents: [] }; 490 | } 491 | 492 | /** 493 | * Build a new server listen message. 494 | * 495 | * This is a local operation. 496 | * 497 | * The resulting message will tell the network that the local actor is 498 | * listening to the server identified by `id` at the given `url`. Actors who 499 | * follow the local actor can make requests to this server to increase 500 | * their ability to get messages from the local actor. 501 | * 502 | * @param id ID of the listened server actor 503 | * @returns the message and object to send 504 | */ 505 | async newListen(did: string): Promise { 506 | const actorDid = this.getLocalDid(); 507 | const actorActorId = ChatterNet.actorFromDid(actorDid); 508 | const actorFollowers = ChatterNet.followersFromId(actorActorId); 509 | const to = [actorFollowers]; 510 | const serverActorId = ChatterNet.actorFromDid(did); 511 | const message = await Model.newMessage(actorDid, [serverActorId], "Listen", null, this.key, { 512 | to, 513 | }); 514 | return { message, documents: [] }; 515 | } 516 | 517 | /** 518 | * Build a new view message. 519 | * 520 | * This is a local operation. 521 | * 522 | * The resulting message will tell the network that the local actor has viewed 523 | * a message, which then allows the followers of the local actor to find that 524 | * message. 525 | * 526 | * @param message the message viewed by the local actor 527 | * @returns the message and object to send 528 | */ 529 | async getOrNewViewMessage(message: Message): Promise { 530 | // don't view messages from self 531 | const actorDid = this.getLocalDid(); 532 | const actorId = ChatterNet.actorFromDid(actorDid); 533 | if (message.actor === actorId) return; 534 | // don't view indirect messages 535 | if (message.type === "View") return; 536 | 537 | // try first to get a previous view message 538 | const [objectId] = message.object; 539 | const previousView = await this.dbs.peer.viewMessage.get(objectId); 540 | if (previousView != null) return previousView; 541 | 542 | const view = await Model.newMessage(actorDid, message.object, "View", null, this.key, { 543 | origin: [message.id], 544 | to: message.to, 545 | }); 546 | await this.dbs.peer.viewMessage.put(view); 547 | return view; 548 | } 549 | 550 | /** 551 | * Add or update a server to the list of of servers known to the local actor. 552 | * 553 | * This is a local operation. 554 | * 555 | * If the server is relevant to the local actor, it might be connected to at 556 | * the next time the a client is built for the local actor. 557 | * 558 | * @param did the DID of the server 559 | * @param url the URL of the server 560 | * @param lastListenTimestamp the last timestamp at which a listen message 561 | * has been emitted for this server 562 | */ 563 | async addOrUpdateServer(did: string, url: string, lastListenTimestamp: number) { 564 | this.dbs.peer.server.update({ info: { url, did }, lastListenTimestamp }); 565 | } 566 | 567 | /** 568 | * Get an object from the global network state. 569 | * 570 | * This will return the requested object from the first server able to serve 571 | * it, or undefined if no server has the object. 572 | * 573 | * @param id the actor ID 574 | * @returns the actor document 575 | */ 576 | async getDocument(id: string, localOnly: boolean = false): Promise { 577 | if (await this.isDeleted(id)) return undefined; 578 | let document: WithId | undefined = undefined; 579 | // try first from local store 580 | if (!document) document = await this.dbs.peer.document.get(id); 581 | if (localOnly) return; 582 | // then from servers 583 | if (!document) document = await this.servers.getDocument(id); 584 | return document; 585 | } 586 | 587 | /** 588 | * Get an object from the global network state. 589 | * 590 | * The mapping of object to message is not maintained locally, so this 591 | * requires a request to the servers. 592 | * 593 | * @param id the actor ID 594 | * @param actorId the ID of the actor which created the message 595 | * @returns the create message 596 | */ 597 | async getCreateMessageForDocument(id: string, actorId: string): Promise { 598 | const message = await this.servers.getCreateMessageForDocument(id, actorId); 599 | return message; 600 | } 601 | 602 | /** 603 | * Get an actor from the global network state. 604 | * 605 | * This will get a document and validate that it is a valid `Actor` object. 606 | * See `getDocument` for more. 607 | * 608 | * @param id the actor ID 609 | * @returns the actor document 610 | */ 611 | async getActor(id: string): Promise { 612 | let actor: WithId | undefined = await this.getDocument(id); 613 | if (!Model.isActor(actor)) return; 614 | if (!(await Model.verifyActor(actor))) return; 615 | return actor; 616 | } 617 | 618 | /** 619 | * Build a new message iterator for the local actor. 620 | * 621 | * This is an object which provides iteration over all inbox messages for 622 | * the local actor, pulled from all servers. 623 | * 624 | * @returns the message iterator 625 | */ 626 | buildMessageIter(): MessageIter { 627 | const uri = `${this.getLocalDid()}/actor/inbox`; 628 | const pageIter = PageIter.new(uri, this.servers, 32, Model.isMessage); 629 | return new MessageIter(this.dbs.peer, pageIter); 630 | } 631 | 632 | /** 633 | * Build a new message iterator for the local actor iterating over only 634 | * messages from actor `actor_id`. 635 | * 636 | * See to [`buildMessageIter`]. 637 | * 638 | * @returns the message iterator 639 | */ 640 | buildMessageIterFrom(actorId: string): MessageIter { 641 | const uri = `${this.getLocalDid()}/actor/inbox/from/${actorId}`; 642 | const pageIter = PageIter.new(uri, this.servers, 32, Model.isMessage); 643 | return new MessageIter(this.dbs.peer, pageIter); 644 | } 645 | 646 | /** 647 | * Build a new message iterator for the local actor iterating over only 648 | * messages with an audience in `audiences`. 649 | * 650 | * See to [`buildMessageIter`]. 651 | * 652 | * @returns the message iterator 653 | */ 654 | buildMessageIterWith(audiences: string[]): MessageIter { 655 | const params = new URLSearchParams({ 656 | audiences: JSON.stringify(audiences), 657 | }); 658 | const uri = `${this.getLocalDid()}/actor/inbox/with?${params.toString()}`; 659 | const pageIter = PageIter.new(uri, this.servers, 32, Model.isMessage); 660 | return new MessageIter(this.dbs.peer, pageIter); 661 | } 662 | 663 | /** 664 | * Build a new iterator over followers. 665 | * 666 | * This is an object which provides iteration over all followers of the local 667 | * actor, pulled from all servers. 668 | * 669 | * @returns the followers iterator 670 | */ 671 | buildFollowersIter(): PageIter { 672 | const uri = `${this.getLocalDid()}/actor/followers`; 673 | const isString = function (x: unknown): x is string { 674 | return typeof x === "string"; 675 | }; 676 | return PageIter.new(uri, this.servers, 32, isString); 677 | } 678 | 679 | /** 680 | * Build a new tag and stores the mapping of its ID to its name. 681 | * 682 | * @param name the tag name 683 | * @returns the `Model.Tag30` object 684 | */ 685 | async buildTag(name: string): Promise { 686 | const tag = await Model.newTag30(name); 687 | return tag; 688 | } 689 | 690 | /** 691 | * Get the DID for the local actor. 692 | * @returns the DID 693 | */ 694 | getLocalDid(): string { 695 | return DidKey.didFromKey(this.key); 696 | } 697 | 698 | /** 699 | * Get the actor ID corresponding to a given DID. 700 | * 701 | * @param did the actor DID 702 | * @returns the actor ID 703 | */ 704 | static actorFromDid(did: string): string { 705 | return `${did}/actor`; 706 | } 707 | 708 | /** 709 | * Get the followers collection ID corresponding to the given object ID. 710 | * 711 | * @param id the document ID 712 | * @returns the followers collection ID 713 | */ 714 | static followersFromId(id: string): string { 715 | return `${id}/followers`; 716 | } 717 | 718 | /** 719 | * Get the user name of the local actor. 720 | * 721 | * @returns the user name 722 | */ 723 | getLocalName(): string { 724 | return this.name; 725 | } 726 | } 727 | --------------------------------------------------------------------------------