diff --git a/deno.lock b/deno.lock index 5339a4f..1cb0ec2 100644 --- a/deno.lock +++ b/deno.lock @@ -197,7 +197,7 @@ "npm:abortable-iterator@^5.0.1", "npm:eventemitter3@^5.0.1", "npm:it-pushable@^3.2.3", - "npm:jose@^5.2.0", + "npm:jose@^5.2.1", "npm:p-defer@^4.0.0", "npm:streaming-iterables@^8.0.1", "npm:tslib@^2.6.2", diff --git a/package.json b/package.json index 1375d58..be5be27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ucla-irl/ndnts-aux", - "version": "1.0.12", + "version": "1.1.0", "description": "NDNts Auxiliary Package for Web and Deno", "scripts": { "test": "deno test --no-check", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f39366..c389139 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,15 +107,15 @@ packages: /@types/imap@0.8.40: resolution: {integrity: sha512-kWFwOc88CGwWZlHqCnZiceS6EralsAHdjpQyk1+fIA875NQdIHvLpdD5NU3Pi1yZ8FKFdOF81UDNAo8/XS6HiQ==} dependencies: - '@types/node': 20.11.16 + '@types/node': 20.11.17 dev: true /@types/minimalistic-assert@1.0.3: resolution: {integrity: sha512-Ku87cam4YxiXcEpeUemo+vO8QWGQ7U2CwEEcLcYFhxG8b4CK8gWxSX/oWjePWKwqPaWWxxVqXAdAjGdlJtVzDA==} dev: true - /@types/node@20.11.16: - resolution: {integrity: sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==} + /@types/node@20.11.17: + resolution: {integrity: sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==} dependencies: undici-types: 5.26.5 dev: true @@ -123,7 +123,7 @@ packages: /@types/nodemailer@6.4.14: resolution: {integrity: sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==} dependencies: - '@types/node': 20.11.16 + '@types/node': 20.11.17 dev: true /@types/retry@0.12.5: @@ -141,7 +141,7 @@ packages: /@types/ws@8.5.10: resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} dependencies: - '@types/node': 20.11.16 + '@types/node': 20.11.17 dev: true /@yoursunny/asn1@0.0.20200718: diff --git a/src/namespace/base-node.ts b/src/namespace/base-node.ts index 199b432..18542cc 100644 --- a/src/namespace/base-node.ts +++ b/src/namespace/base-node.ts @@ -1,36 +1,63 @@ import { Endpoint } from '@ndn/endpoint'; -import type { Data, Interest } from '@ndn/packet'; +import type { Data, Interest, Verifier } from '@ndn/packet'; import * as namePattern from './name-pattern.ts'; import * as schemaTree from './schema-tree.ts'; import { EventChain } from '../utils/event-chain.ts'; +import { NamespaceHandler } from './nt-schema.ts'; export interface BaseNodeEvents { attach(path: namePattern.Pattern, endpoint: Endpoint): Promise; - detach(): Promise; + detach(endpoint: Endpoint): Promise; } export class BaseNode { public readonly onAttach = new EventChain(); public readonly onDetach = new EventChain(); - protected endpoint: Endpoint | undefined = undefined; + protected handler: NamespaceHandler | undefined = undefined; + + public get namespaceHandler() { + return this.handler; + } public processInterest( - matched: schemaTree.MatchedObject, + matched: schemaTree.StrictMatch, interest: Interest, + deadline: number, ): Promise { - console.warn(`Silently drop unprocessable Interest ${matched.name}: ${interest.appParameters}`); + console.warn(`Silently drop unprocessable Interest ${matched.name.toString()}: ${interest.appParameters}`); + deadline; // Silence warning return Promise.resolve(undefined); } - public async processAttach(path: namePattern.Pattern, endpoint: Endpoint) { + public verifyPacket( + matched: schemaTree.StrictMatch, + pkt: Verifier.Verifiable, + deadline: number, + ) { + console.warn(`Silently drop unverified packet ${matched.name.toString()}`); + pkt; + deadline; + return Promise.resolve(false); + } + + public storeData( + matched: schemaTree.StrictMatch, + data: Data, + ) { + console.warn(`Not store unexpected Data ${matched.name.toString()}`); + data; + return Promise.resolve(); + } + + public async processAttach(path: namePattern.Pattern, handler: NamespaceHandler) { // All children's attach events are called - this.endpoint = endpoint; - await this.onAttach.emit(path, endpoint); + this.handler = handler; + await this.onAttach.emit(path, handler.endpoint); } public async processDetach() { - await this.onDetach.emit(); - this.endpoint = undefined; + await this.onDetach.emit(this.handler!.endpoint); + this.handler = undefined; // Then call children's detach } } diff --git a/src/namespace/expressing-point.ts b/src/namespace/expressing-point.ts index 77ae026..46be9d2 100644 --- a/src/namespace/expressing-point.ts +++ b/src/namespace/expressing-point.ts @@ -1,44 +1,196 @@ -import { Endpoint, RetxPolicy } from '@ndn/endpoint'; -import { Data, Interest, Name, Signer, type Verifier } from '@ndn/packet'; -import * as namePattern from './name-pattern.ts'; +import { RetxPolicy } from '@ndn/endpoint'; +import { Data, Interest, Signer, type Verifier } from '@ndn/packet'; import * as schemaTree from './schema-tree.ts'; import { BaseNode, BaseNodeEvents } from './base-node.ts'; -import { EventChain } from '../utils/event-chain.ts'; - -export enum VerifyResult { - Fail = -1, - Unknown = 0, - Pass = 1, - Bypass = 2, -} +import { EventChain, Stop } from '../utils/event-chain.ts'; +import { VerifyResult } from './nt-schema.ts'; export interface ExpressingPointEvents extends BaseNodeEvents { - interest(target: schemaTree.StrictMatch): Promise; - verify(target: schemaTree.StrictMatch, pkt: Verifier.Verifiable): Promise; - searchStorage(target: schemaTree.StrictMatch): Promise; - saveStorage(target: schemaTree.StrictMatch): Promise; + interest( + args: { + target: schemaTree.StrictMatch; + interest: Interest; + deadline: number; + }, + ): Promise; + + verify( + args: { + target: schemaTree.StrictMatch; + deadline: number; + pkt: Verifier.Verifiable; + }, + ): Promise; + + searchStorage( + args: { + target: schemaTree.StrictMatch; + interest: Interest; + deadline: number; + }, + ): Promise; } export type ExpressingPointOpts = { lifetimeMs: number; - signer: Signer; + interestSigner?: Signer; + canBePrefix?: boolean; + mustBeFresh?: boolean; supressInterest?: boolean; - abortSignal?: AbortSignal; - modifyInterest?: Interest.Modify; retx?: RetxPolicy; }; export class ExpressingPoint extends BaseNode { + /** Called when Interest received */ public readonly onInterest = new EventChain(); + + /** Verify Interest event. Also verifies Data if this is a LeafNode */ public readonly onVerify = new EventChain(); + + /** Searching stored data from the storage */ public readonly onSearchStorage = new EventChain(); - public readonly onSaveStorage = new EventChain(); - // public async need(matched: schemaTree.MatchedObject): Promise {} + constructor( + public readonly config: ExpressingPointOpts, + ) { + super(); + } + + public searchCache(target: schemaTree.StrictMatch, interest: Interest, deadline: number) { + return this.onSearchStorage.chain( + undefined, + (ret) => Promise.resolve(ret ? Stop : [{ target, interest, deadline }]), + { target, interest, deadline }, + ); + } + + public override async verifyPacket( + matched: schemaTree.StrictMatch, + pkt: Verifier.Verifiable, + deadline: number, + ) { + const verifyResult = await this.onVerify.chain( + VerifyResult.Unknown, + (ret, args) => Promise.resolve((ret < VerifyResult.Unknown || ret >= VerifyResult.Bypass) ? Stop : [args]), + { target: matched, pkt, deadline }, + ); + return verifyResult >= VerifyResult.Pass; + } + + public override async processInterest( + matched: schemaTree.StrictMatch, + interest: Interest, + deadline: number, + ): Promise { + // Search storage + // Reply if there is data (including AppNack). No further callback will be called if hit. + // This is the same behavior as a forwarder. + const cachedData = await this.searchCache(matched, interest, deadline); + if (cachedData) { + return cachedData; + } + + // Validate Interest + // Only done when there is a sigInfo or appParam. + // Signed Interests are required to carry AppParam, but may be zero length. + // To guarantee everything is good in case the underlying library returns `undefined` when zero length, check both. + if (interest.appParameters || interest.sigInfo) { + if (!await this.verifyPacket(matched, interest, deadline)) { + // Unverified Interest. Drop + return; + } + } + + // PreRecvInt + // Used to decrypt AppParam or handle before onInterest hits, if applicable. + // Do we need them? Hold for now. + + // OnInt + const result = await this.onInterest.chain( + undefined, + (ret, args) => Promise.resolve(ret ? Stop : [args]), + { target: matched, interest, deadline }, + ); + + // PreSendData + // Used to encrypt Data or handle after onInterest hits, if applicable. + // Do we need them? Hold for now. + return result; + } + + public async need( + matched: schemaTree.StrictMatch, + opts: { + appParam?: Uint8Array | string; + supressInterest?: boolean; + abortSignal?: AbortSignal; + signer?: Signer; + lifetimeMs?: number; + deadline?: number; + } = {}, + ): Promise { + // Construct Interest, but without signing, so the parameter digest is not there + const interestArgs = [matched.name] as Array; + if (this.config.canBePrefix) { + // Be aware that if CanBePrefix is set, you may need to also validate the data against the LeafNode's validator. + interestArgs.push(Interest.CanBePrefix); + } + if (this.config.mustBeFresh ?? true) { + interestArgs.push(Interest.MustBeFresh); + } + const appParam = opts.appParam instanceof Uint8Array + ? opts.appParam + : typeof opts.appParam === 'string' + ? new TextEncoder().encode(opts.appParam) + : undefined; + if (appParam) { + interestArgs.push(appParam); + } + // TODO: FwHint is not supported for now. Who should provide this info? + const lifetimeMs = opts.lifetimeMs ?? this.config.lifetimeMs; + interestArgs.push(Interest.Lifetime(lifetimeMs)); + const interest = new Interest(...interestArgs); + + // Compute deadline + const deadline = opts.deadline ?? (Date.now() + lifetimeMs); + + // Get a signer for this interest + const signer = opts.signer ?? this.config.interestSigner; + + // If appParam is empty and not signed, the Interest name is final. + // Otherwise, we have to construct the Interest first before searching storage. + // Get a signer for Interest. + let cachedData: Data | undefined = undefined; + if (!appParam && !signer) { + cachedData = await this.searchCache(matched, interest, deadline); + if (cachedData) { + return cachedData; + } + } + + // After signing the digest is there + if (signer) { + await signer.sign(interest); + } + // We may search the storage if not yet. However, it seems not useful for now. + + // Express the Interest if not surpressed + const supressInterest = opts.supressInterest ?? this.config.supressInterest; + if (supressInterest) { + return undefined; + } + + const data = await this.handler!.endpoint.consume(interest, { + // deno-lint-ignore no-explicit-any + signal: opts.abortSignal as any, + retx: this.config.retx, + // Note: the verifier is at the LeafNode if CanBePrefix is set + verifier: this.handler!.getVerifier(deadline), + }); + + // (no await) Save (cache) the data in the storage + this.handler!.storeData(data); - // public override async processInterest( - // matched: schemaTree.MatchedObject, - // interest: Interest, - // ): Promise { - // } + return data; + } } diff --git a/src/namespace/leaf-node.ts b/src/namespace/leaf-node.ts index e69de29..7674b83 100644 --- a/src/namespace/leaf-node.ts +++ b/src/namespace/leaf-node.ts @@ -0,0 +1,90 @@ +import { Component, Data, Signer } from '@ndn/packet'; +import { Encoder } from '@ndn/tlv'; +import * as schemaTree from './schema-tree.ts'; +import { EventChain } from '../utils/event-chain.ts'; +import { ExpressingPointEvents, ExpressingPointOpts } from './expressing-point.ts'; +import { ExpressingPoint } from './expressing-point.ts'; + +export interface LeafNodeEvents extends ExpressingPointEvents { + saveStorage( + args: { + target: schemaTree.StrictMatch; + data: Data; + wire: Uint8Array; + validUntil: number; + }, + ): Promise; +} + +export type LeafNodeOpts = ExpressingPointOpts & { + freshnessMs: number; + signer: Signer; + validityMs?: number; + contentType?: number; +}; + +export class LeafNode extends ExpressingPoint { + /** Save a Data into the storage */ + public readonly onSaveStorage = new EventChain(); + + constructor( + public readonly config: LeafNodeOpts, + ) { + super(config); + } + + public override async storeData( + matched: schemaTree.StrictMatch, + data: Data, + ) { + const wire = Encoder.encode(data); + const validity = this.config.validityMs ?? 876000 * 3600000; + const validUntil = Date.now() + validity; + // Save Data into storage + await this.onSaveStorage.emit({ + target: matched, + data, + wire, + validUntil, + }); + } + + public async provide( + matched: schemaTree.StrictMatch, + content: Uint8Array | string, + opts: { + freshnessMs?: number; + validityMs?: number; + signer?: Signer; + finalBlockId?: Component; + } = {}, + ): Promise { + const payload = content instanceof Uint8Array ? content : new TextEncoder().encode(content); + + // Create Data + const data = new Data( + matched.name, + Data.ContentType(this.config.contentType ?? 0), // Default is BLOB + Data.FreshnessPeriod(opts.freshnessMs ?? this.config.freshnessMs), + payload, + ); + if (opts.finalBlockId) { + data.finalBlockId = opts.finalBlockId; + } + await this.config.signer.sign(data); + + const wire = Encoder.encode(data); + const validity = this.config.validityMs ?? 876000 * 3600000; + const validUntil = Date.now() + validity; + + // Save Data into storage + await this.onSaveStorage.emit({ + target: matched, + data, + wire, + validUntil, + }); + + return wire; + } +} diff --git a/src/namespace/nt-schema.ts b/src/namespace/nt-schema.ts new file mode 100644 index 0000000..c1cd528 --- /dev/null +++ b/src/namespace/nt-schema.ts @@ -0,0 +1,74 @@ +import { Endpoint } from '@ndn/endpoint'; +import { Data, Interest, Name, type Verifier } from '@ndn/packet'; +// import * as namePattern from './name-pattern.ts'; +import * as schemaTree from './schema-tree.ts'; +import { type BaseNode } from './base-node.ts'; + +export enum VerifyResult { + Fail = -2, + Timeout = -1, + Unknown = 0, + Pass = 1, + Bypass = 2, + CachedData = 3, +} + +export interface NamespaceHandler { + get endpoint(): Endpoint; + getVerifier(deadline: number): Verifier; + storeData(data: Data): Promise; +} + +export class NtSchema implements NamespaceHandler { + public readonly tree = schemaTree.create(); + protected _endpoint: Endpoint | undefined; + protected _attachedPrefix: Name | undefined; + + get endpoint(): Endpoint { + return this._endpoint!; + } + + public match(name: Name) { + if (!this._attachedPrefix?.isPrefixOf(name)) { + return undefined; + } + const prefixLength = this._attachedPrefix!.length; + return schemaTree.match(this.tree, name.slice(prefixLength)); + } + + public getVerifier(deadline: number): Verifier { + return { + verify: async (pkt: Verifier.Verifiable) => { + const matched = this.match(pkt.name); + if (!matched || !matched.resource) { + throw new Error('Unexpected packet'); + } + if (!await schemaTree.call(matched, 'verifyPacket', pkt, deadline)) { + throw new Error('Unverified packet'); + } + }, + }; + } + + public async storeData(data: Data): Promise { + const matched = this.match(data.name); + if (matched && matched.resource) { + await schemaTree.call(matched, 'storeData', data); + } + } + + public async onInterest(interest: Interest): Promise { + const matched = this.match(interest.name); + if (matched && matched.resource) { + return await schemaTree.call(matched, 'processInterest', interest, Date.now() + interest.lifetime); + } + return undefined; + } + + // TODO: schemaTree.traverse + // public async attach(prefix: Name, endpoint: Endpoint) { + // } + + // public async detach() { + // } +} diff --git a/src/namespace/schema-tree.test.ts b/src/namespace/schema-tree.test.ts index 9521aa0..60cbe4c 100644 --- a/src/namespace/schema-tree.test.ts +++ b/src/namespace/schema-tree.test.ts @@ -23,7 +23,7 @@ class Reflector { public readonly nodeId: string, ) {} - public reflect(matched: schemaTree.MatchedObject, mapping: TestCollector, reqId: string) { + public reflect(matched: schemaTree.StrictMatch, mapping: TestCollector, reqId: string) { mapping[reqId] = { nodeId: this.nodeId, reqId, diff --git a/src/namespace/schema-tree.ts b/src/namespace/schema-tree.ts index 81ff2e4..3c234e3 100644 --- a/src/namespace/schema-tree.ts +++ b/src/namespace/schema-tree.ts @@ -181,19 +181,19 @@ export const call = < R, Key extends { // deno-lint-ignore no-explicit-any - [K in keyof R]: R[K] extends ((matchedObj: MatchedObject, ...args: any[]) => unknown) ? K : never; + [K in keyof R]: R[K] extends ((matchedObj: StrictMatch, ...args: any[]) => unknown) ? K : never; }[keyof R], >( object: MatchedObject, key: Key, - ...args: R[Key] extends ((matchedObj: MatchedObject, ...args: infer Args) => unknown) ? Args : never + ...args: R[Key] extends ((matchedObj: StrictMatch, ...args: infer Args) => unknown) ? Args : never ) => { if (object.resource) { const func = object.resource[key] as (( - matchedObj: MatchedObject, + matchedObj: StrictMatch, ...params: typeof args - ) => R[Key] extends (matchedObj: MatchedObject, ...params: typeof args) => infer Return ? Return : never); - return func(object, ...args); + ) => R[Key] extends (matchedObj: StrictMatch, ...params: typeof args) => infer Return ? Return : never); + return func.bind(object.resource)(object as StrictMatch, ...args); } else { throw new Error(`Invalid schema tree node call on ${object.name.toString()}`); } diff --git a/src/sync-agent/namespace.ts b/src/sync-agent/namespace.ts index 2eb49a2..98814f5 100644 --- a/src/sync-agent/namespace.ts +++ b/src/sync-agent/namespace.ts @@ -22,7 +22,7 @@ export type SyncAgentNamespace = { /** * The base name of the SVS, i.e. `//` in the Spec. * We have this function because the spec does not clearly say how to handle the application prefix. - * Default is: baseName(/ndn-app/node/1, /ndn-app/sync/late) = /ndn-app/node/1/ndn-app/sync/late + * Default is: baseName(/ndn-app/node/1, /ndn-app/sync/late) = /ndn-app/node/1/sync/late * @param nodeId the nodeID, e.g. /ndn-app/node/1 * @param syncPrefix the sync group prefix, e.g. /ndn-app/sync/late */ @@ -90,7 +90,8 @@ function createDefaultNamespace(): SyncAgentNamespace { }, baseName(nodeId: Name, syncPrefix: Name): Name { // append is side-effect free - return nodeId.append(...syncPrefix.comps); + const groupPrefix = syncPrefix.slice(nodeId.length - 1); // nodeId.length - 1 is appPrefix + return nodeId.append(...groupPrefix.comps); }, syncStateKey(baseName: Name): string { return '/8=local' + baseName.toString() + '/8=syncVector';