import { deserialize, field, serialize, variant } from "@dao-xyz/borsh"; import { type PeerId } from "@libp2p/interface"; import { PublicSignKey, getPublicKeyFromPeerId, sha256Base64Sync, } from "@peerbit/crypto"; import { type CanPerformOperations, type CanRead, Documents, type DocumentsLike, type Operation, SearchRequest, policy, } from "@peerbit/document"; import { type AppendOptions } from "@peerbit/log"; import { Program } from "@peerbit/program"; import { type ReplicationOptions } from "@peerbit/shared-log"; import { FromTo, IdentityRelation, createIdentityGraphStore, getFromByToLocalOnly, getPathGenerator, getToByFrom, hasPath, getRelation as resolveRelation, } from "./identity-graph.js"; const openDocumentsLike = async = any>( owner: Program, docs: DocumentsLike, args: any, ): Promise> => { if (!(docs instanceof Program)) { return docs; } const opened = await owner.node.open(docs as Documents, { args, parent: owner as any, existing: "reuse", }); if (opened instanceof Documents && !(opened as any)._clazz) { await opened.open(args); } return opened as DocumentsLike; }; const coercePublicKey = (publicKey: PublicSignKey | PeerId): PublicSignKey => { if (publicKey instanceof PublicSignKey) { return publicKey; } const bytes = (publicKey as { bytes?: unknown })?.bytes; if (bytes instanceof Uint8Array) { return deserialize(bytes, PublicSignKey); } return getPublicKeyFromPeerId(publicKey as PeerId); }; const canPerformByRelation = async ( properties: CanPerformOperations, isTrusted?: (key: PublicSignKey) => Promise, ): Promise => { // verify the payload const keys = await properties.entry.getPublicKeys(); const checkKey = async (key: PublicSignKey): Promise => { if (properties.type === "put") { try { const from = coercePublicKey(properties.value.from); const signer = coercePublicKey(key); if (!from.equals(signer)) { return false; } } catch { return false; } } if (isTrusted) { const trusted = await isTrusted(key); return trusted; } else { return true; } }; for (const key of keys) { const result = await checkKey(key); if (result) { return true; } } return false; }; const defaultIdentityGraphCanPerform = policy.or( policy.put(policy.signedByField("_from")), policy.delete(policy.allowAll()), ); type IdentityGraphArgs = { canRead?: CanRead; replicate?: ReplicationOptions; }; @variant("relations") export class IdentityGraph extends Program { @field({ type: Documents }) relationGraph: DocumentsLike; constructor(props?: { id?: Uint8Array; relationGraph?: DocumentsLike; }) { super(); if (props) { this.relationGraph = props.relationGraph || createIdentityGraphStore(props?.id); } } async canPerform( properties: CanPerformOperations, ): Promise { return canPerformByRelation(properties); } async open(options?: IdentityGraphArgs) { const canPerform = this.canPerform === IdentityGraph.prototype.canPerform ? defaultIdentityGraphCanPerform : this.canPerform.bind(this); this.relationGraph = await openDocumentsLike(this, this.relationGraph, { type: IdentityRelation, canPerform, replicate: options?.replicate, index: { canRead: options?.canRead, type: FromTo, }, }); } async addRelation( to: PublicSignKey | PeerId, options?: AppendOptions, ) { /* trustee = PublicKey.from(trustee); */ await this.relationGraph.put( new IdentityRelation({ to: coercePublicKey(to), from: coercePublicKey( options?.identity?.publicKey || this.node.identity.publicKey, ), }), options, ); } } /** * Not shardeable since we can not query trusted relations, because this would lead to a recursive problem where we then need to determine whether the responder is trusted or not */ type TrustedNetworkArgs = { replicate?: ReplicationOptions }; @variant("trusted_network") export class TrustedNetwork extends Program { @field({ type: PublicSignKey }) rootTrust: PublicSignKey; @field({ type: Documents }) trustGraph: DocumentsLike; // Best-effort remote warmup throttle for `isTrusted()` calls when the local graph is empty. // Access control should be conservative (default deny) and non-blocking, so this is fire-and-forget. private _lastWarmupAt = 0; constructor(props: { id?: Uint8Array; rootTrust: PublicSignKey | PeerId }) { super(); this.rootTrust = coercePublicKey(props.rootTrust); this.trustGraph = createIdentityGraphStore(props.id); } async open(options?: TrustedNetworkArgs) { this.trustGraph = this.trustGraph || createIdentityGraphStore(); this.trustGraph = await openDocumentsLike(this, this.trustGraph, { type: IdentityRelation, canPerform: this.canPerform.bind(this), replicate: options?.replicate || { factor: 1, }, index: { canRead: this.canRead.bind(this), type: FromTo, }, }); // self referencing access controller } async canPerform( properties: CanPerformOperations, ): Promise { return canPerformByRelation(properties, (key) => this.isTrusted(key)); } async canRead(relation: any, publicKey?: PublicSignKey): Promise { return true; // TODO should we have read access control? } async add( trustee: PublicSignKey | PeerId, options?: AppendOptions, ) { const key = coercePublicKey(trustee); const truster = coercePublicKey(this.node.identity.publicKey); const existingRelation = await this.getRelation(key, truster); if (!existingRelation) { const relation = new IdentityRelation({ to: key, from: truster, }); await this.trustGraph!.put(relation); return relation; } return existingRelation; } async hasRelation( trustee: PublicSignKey | PeerId, truster: PublicSignKey | PeerId = this.rootTrust, ) { return !!(await this.getRelation(trustee, truster)); } getRelation( trustee: PublicSignKey | PeerId, truster: PublicSignKey | PeerId = this.rootTrust, ) { return resolveRelation( coercePublicKey(trustee), coercePublicKey(truster), this.trustGraph!, ); } /** * Follow trust path back to trust root. * Trust root is always trusted. * Hence if * Root trust A trust B trust C * C is trusted by Root * @param trustee * @param truster the truster "root", if undefined defaults to the root trust * @returns true, if trusted */ async isTrusted( trustee: PublicSignKey | PeerId, truster: PublicSignKey | PeerId = this.rootTrust, ): Promise { const trusteeKey = coercePublicKey(trustee); const trusterKey = coercePublicKey(truster); if (trusteeKey.equals(this.rootTrust)) { return true; } // Fast local-only check first. This avoids stalling writes on cold-start or churn. if (await this._isTrustedLocal(trusteeKey, trusterKey)) { return true; } // Best-effort: kick off a background remote query to encourage replication metadata to converge. // Do not await; access control remains default-deny until local state reflects trust. const now = Date.now(); if (now - this._lastWarmupAt > 2_000) { this._lastWarmupAt = now; void this.trustGraph.index .search(new SearchRequest({ query: [] }), { remote: { replicate: true }, }) .catch(() => {}); } return false; } async _isTrustedLocal( trustee: PublicSignKey, truster: PublicSignKey = this.rootTrust, ): Promise { const trustPath = await hasPath( trustee, truster, this.trustGraph, getFromByToLocalOnly, ); return !!trustPath; } async getTrusted(): Promise { const current = this.rootTrust; const participants: PublicSignKey[] = [current]; const generator = getPathGenerator(current, this.trustGraph, getToByFrom); for await (const next of generator) { participants.push(next.to); } return participants; } hashCode(): string { return sha256Base64Sync(serialize(this)); } } /* TODO do we need these decorator functions? export const getNetwork = (object: any): TrustedNetwork | undefined => { return ( object.constructor.prototype._network && object[object.constructor.prototype._network] ); }; export function network(options: { property: string }) { return (constructor: any) => { constructor.prototype._network = options.property; }; } */