From 7027542c2865189deecf58d4f512cf1a62486fe9 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 11:42:04 +0100 Subject: [PATCH 1/5] feat(principal): use webcrypto ed25519 non-extractable keys by default --- packages/principal/src/ed25519/signer.js | 125 ++++++++++++++++++++- packages/principal/src/ed25519/type.ts | 3 +- packages/principal/src/ed25519/verifier.js | 37 +++++- packages/principal/test/ed25519.spec.js | 90 ++++++++++++--- 4 files changed, 234 insertions(+), 21 deletions(-) diff --git a/packages/principal/src/ed25519/signer.js b/packages/principal/src/ed25519/signer.js index b19fa836..235746b3 100644 --- a/packages/principal/src/ed25519/signer.js +++ b/packages/principal/src/ed25519/signer.js @@ -1,4 +1,5 @@ import * as ED25519 from '@noble/ed25519' +import { webcrypto } from 'one-webcrypto' import { varint } from 'multiformats' import * as API from './type.js' import * as Verifier from './verifier.js' @@ -23,9 +24,33 @@ export const PUB_KEY_OFFSET = PRIVATE_TAG_SIZE + KEY_SIZE /** * Generates new issuer by generating underlying ED25519 keypair. + * @param {{extractable?: boolean}} [options] * @returns {Promise} */ -export const generate = () => derive(ED25519.utils.randomPrivateKey()) +export const generate = async ({ extractable = false } = {}) => { + if (extractable) { + return derive(ED25519.utils.randomPrivateKey()) + } + + const keypair = /** @type {CryptoKeyPair} */ ( + await webcrypto.subtle.generateKey({ name: 'Ed25519' }, false, [ + 'sign', + 'verify', + ]) + ) + + const raw = new Uint8Array( + await webcrypto.subtle.exportKey('raw', keypair.publicKey) + ) + const bytes = new Uint8Array(PUBLIC_TAG_SIZE + KEY_SIZE) + varint.encodeTo(Verifier.code, bytes, 0) + bytes.set(raw, PUBLIC_TAG_SIZE) + + return new UnextractableEd25519Signer({ + privateKey: keypair.privateKey, + verifier: Verifier.decode(bytes), + }) +} /** * Derives issuer from 32 byte long secret key. @@ -57,9 +82,20 @@ export const derive = async secret => { */ export const from = ({ id, keys }) => { if (id.startsWith('did:key:')) { - const key = keys[/** @type {API.DIDKey} */ (id)] + const did = /** @type {API.DIDKey} */ (id) + const key = keys[did] if (key instanceof Uint8Array) { return decode(key) + } else if ( + key && + key.type === 'private' && + key.algorithm && + key.algorithm.name === 'Ed25519' + ) { + return new UnextractableEd25519Signer({ + privateKey: key, + verifier: /** @type {API.EdVerifier} */ (Verifier.parse(did)), + }) } } throw new TypeError(`Unsupported archive format`) @@ -222,3 +258,88 @@ class Ed25519Signer extends Uint8Array { } } } + +/** + * @implements {API.EdSigner} + */ +class UnextractableEd25519Signer { + /** + * @param {object} options + * @param {CryptoKey} options.privateKey + * @param {API.EdVerifier} options.verifier + */ + constructor({ privateKey, verifier }) { + this.privateKey = privateKey + this.verifier = verifier + } + + /** @type {typeof code} */ + get code() { + return code + } + + get signer() { + return this + } + + did() { + return this.verifier.did() + } + + toDIDKey() { + return this.verifier.toDIDKey() + } + + /** + * @template {API.DID} ID + * @param {ID} id + * @returns {API.Signer} + */ + withDID(id) { + return Signer.withDID(this, id) + } + + /** + * @template T + * @param {API.ByteView} payload + * @returns {Promise>} + */ + async sign(payload) { + const raw = new Uint8Array( + await webcrypto.subtle.sign({ name: 'Ed25519' }, this.privateKey, payload) + ) + return Signature.create(this.signatureCode, raw) + } + + /** + * @template T + * @param {API.ByteView} payload + * @param {API.Signature} signature + */ + verify(payload, signature) { + return this.verifier.verify(payload, signature) + } + + get signatureAlgorithm() { + return signatureAlgorithm + } + + get signatureCode() { + return Signature.EdDSA + } + + /** + * @returns {API.ByteView} + */ + encode() { + throw new TypeError('Unextractable ed25519 key can not be encoded') + } + + toArchive() { + const id = this.did() + return { + id, + keys: { [id]: this.privateKey }, + } + } +} diff --git a/packages/principal/src/ed25519/type.ts b/packages/principal/src/ed25519/type.ts index 965b96af..d8f86f5e 100644 --- a/packages/principal/src/ed25519/type.ts +++ b/packages/principal/src/ed25519/type.ts @@ -4,6 +4,7 @@ import { MulticodecCode, ByteView, DIDKey, + KeyArchive, } from '@ucanto/interface' import * as Signature from '@ipld/dag-ucan/signature' @@ -44,7 +45,7 @@ export interface EdSigner extends SignerKey { */ toArchive(): { id: DIDKey - keys: { [Key: DIDKey]: ByteView & CryptoKey> } + keys: { [Key: DIDKey]: KeyArchive } } } diff --git a/packages/principal/src/ed25519/verifier.js b/packages/principal/src/ed25519/verifier.js index e15018eb..d6bf64e6 100644 --- a/packages/principal/src/ed25519/verifier.js +++ b/packages/principal/src/ed25519/verifier.js @@ -1,5 +1,5 @@ import * as DID from '@ipld/dag-ucan/did' -import * as ED25519 from '@noble/ed25519' +import { webcrypto } from 'one-webcrypto' import { varint } from 'multiformats' import * as API from './type.js' import * as Signature from '@ipld/dag-ucan/signature' @@ -109,9 +109,38 @@ class Ed25519Verifier extends Uint8Array { * @returns {API.Await} */ verify(payload, signature) { - return ( - signature.code === signatureCode && - ED25519.verify(signature.raw, payload, this.publicKey) + if (signature.code !== signatureCode) { + return false + } + + return this.verifyWithWebCrypto(payload, signature) + } + + /** + * @template T + * @param {API.ByteView} payload + * @param {API.Signature} signature + * @returns {Promise} + */ + async verifyWithWebCrypto(payload, signature) { + const state = /** @type {{cryptoKey?: Promise}} */ (this) + const key = + state.cryptoKey || + webcrypto.subtle.importKey( + 'raw', + this.publicKey, + { name: 'Ed25519' }, + true, + ['verify'] + ) + + state.cryptoKey = key + + return webcrypto.subtle.verify( + { name: 'Ed25519' }, + await key, + signature.raw, + payload ) } diff --git a/packages/principal/test/ed25519.spec.js b/packages/principal/test/ed25519.spec.js index 59a6ffdf..b818e788 100644 --- a/packages/principal/test/ed25519.spec.js +++ b/packages/principal/test/ed25519.spec.js @@ -2,6 +2,7 @@ import { ed25519, ed25519 as Lib } from '../src/lib.js' import { assert } from 'chai' import { sha256 } from 'multiformats/hashes/sha2' import { varint } from 'multiformats' +import { webcrypto } from 'one-webcrypto' describe('signing principal', () => { const { Signer } = Lib @@ -45,8 +46,19 @@ describe('signing principal', () => { assert.equal(signer.did(), verifier.did()) }) + it('generate non extractable by default', async () => { + const signer = await Lib.generate() + const { id, keys } = signer.toArchive() + const key = /** @type {CryptoKey} */ (keys[id]) + + assert.equal(key.type, 'private') + assert.deepEqual(Object(key.algorithm), { name: 'Ed25519' }) + assert.equal(key.extractable, false) + assert.deepEqual(key.usages, ['sign']) + }) + it('derive', async () => { - const original = await Lib.generate() + const original = await Lib.generate({ extractable: true }) // @ts-expect-error - secret is not defined by interface const derived = await Lib.derive(original.secret) @@ -57,7 +69,7 @@ describe('signing principal', () => { it('derive throws on bad input', async () => { // @ts-expect-error - secret is not defined by interface - const { secret } = await Lib.generate() + const { secret } = await Lib.generate({ extractable: true }) try { await Lib.derive(secret.subarray(1)) assert.fail('Expected to throw') @@ -67,22 +79,26 @@ describe('signing principal', () => { }) it('SigningPrincipal.decode', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) const bytes = Signer.encode(signer) const { id, keys } = signer.toArchive() + const key = keys[id] + if (!(key instanceof Uint8Array)) { + return assert.fail('Expected archive key to be Uint8Array') + } - assert.deepEqual(Signer.decode(keys[id]), signer) + assert.deepEqual(Signer.decode(key), signer) - const invalid = new Uint8Array(keys[id]) + const invalid = new Uint8Array(key) varint.encodeTo(4, invalid, 0) assert.throws(() => Signer.decode(invalid), /must be a multiformat with/) assert.throws( - () => Signer.decode(keys[id].slice(0, 32)), + () => Signer.decode(key.slice(0, 32)), /Expected Uint8Array with byteLength/ ) - const malformed = new Uint8Array(keys[id]) + const malformed = new Uint8Array(key) // @ts-ignore varint.encodeTo(4, malformed, Signer.PUB_KEY_OFFSET) @@ -90,22 +106,35 @@ describe('signing principal', () => { }) it('SigningPrincipal decode encode roundtrip', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) assert.deepEqual(Signer.decode(Signer.encode(signer)), signer) }) it('SigningPrincipal.format', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) assert.deepEqual(Signer.parse(Signer.format(signer)), signer) }) it('SigningPrincipal.did', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) assert.equal(signer.did().startsWith('did:key:'), true) }) + + it('extractable signer supports Signer interface helpers', async () => { + const signer = await Lib.generate({ extractable: true }) + const alias = signer.withDID('did:web:example.com') + const payload = new TextEncoder().encode('hello world') + const signature = await signer.sign(payload) + + assert.equal(signer.code, 0x1300) + assert.equal(signer.signer, signer) + assert.equal(signer.toDIDKey(), signer.did()) + assert.equal(signer.signatureAlgorithm, 'EdDSA') + assert.equal(await alias.verify(payload, signature), true) + }) }) describe('principal', () => { @@ -118,10 +147,13 @@ describe('principal', () => { }) it('Verifier.parse', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) const verifier = Verifier.parse(signer.did()) const { id, keys } = signer.toArchive() const bytes = keys[id] + if (!(bytes instanceof Uint8Array)) { + return assert.fail('Expected archive key to be Uint8Array') + } assert.deepEqual( new Uint8Array(bytes.buffer, bytes.byteOffset + Signer.PUB_KEY_OFFSET), @@ -131,9 +163,12 @@ describe('principal', () => { }) it('Verifier.decode', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) const { id, keys } = signer.toArchive() const bytes = keys[id] + if (!(bytes instanceof Uint8Array)) { + return assert.fail('Expected archive key to be Uint8Array') + } const verifier = new Uint8Array( bytes.buffer, @@ -149,7 +184,7 @@ describe('principal', () => { }) it('Verifier.format', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) const verifier = Verifier.parse(signer.did()) assert.deepEqual(Verifier.format(verifier), signer.did()) @@ -163,7 +198,7 @@ describe('principal', () => { }) it('signer toArchive', async () => { - const signer = await Lib.generate() + const signer = await Lib.generate({ extractable: true }) assert.deepEqual( { @@ -192,4 +227,31 @@ describe('principal', () => { const payload = new TextEncoder().encode('hello world') assert.equal(await ed.verify(payload, await ed.sign(payload)), true) }) + + it('can archive and restore non extractable key', async () => { + const signer = await Lib.generate() + const archive = signer.toArchive() + const restored = Signer.from(archive) + const payload = new TextEncoder().encode('hello world') + + const signature = await restored.sign(payload) + assert.equal(await signer.verify(payload, signature), true) + assert.equal(await restored.verify(payload, signature), true) + + const key = /** @type {CryptoKey} */ (archive.keys[archive.id]) + try { + await webcrypto.subtle.exportKey('pkcs8', key) + assert.fail('Expected exportKey(pkcs8) to fail for non extractable key') + } catch (error) { + assert.match(String(error), /extractable/i) + } + }) + + it('can not encode non extractable key', async () => { + const signer = await Lib.generate() + assert.throws( + () => Signer.encode(signer), + /Unextractable ed25519 key can not be encoded/ + ) + }) }) From 8890008bcb8d36f582625c1f2d0e4346ed738584 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 11:43:50 +0100 Subject: [PATCH 2/5] chore: apply prettier formatting across workspace files --- .github/workflows/release.yml | 3 +- packages/client/test/services/util.js | 6 ++-- packages/core/src/schema/did.js | 2 +- packages/core/test/cbor.spec.js | 2 +- packages/core/test/delegation.spec.js | 12 ++++---- packages/core/test/extra-schema.spec.js | 41 +++++++++++++++++++------ packages/core/test/utils.js | 2 +- packages/interface/src/lib.ts | 8 +++-- packages/server/src/handler.js | 3 +- packages/server/src/server.js | 11 ++++--- packages/server/test/server.spec.js | 30 ++++++++++-------- packages/transport/src/http.js | 5 ++- packages/transport/test/https.spec.js | 7 +++-- packages/transport/test/util.js | 2 +- packages/validator/src/error.js | 2 +- packages/validator/src/lib.js | 14 ++++----- packages/validator/test/session.spec.js | 23 ++++++++------ 17 files changed, 108 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37a8a29d..7c6865da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: if: ${{needs.release.outputs.releases_created}} runs-on: ubuntu-latest permissions: - id-token: write # Required for OIDC + id-token: write # Required for OIDC steps: # The logic below handles the npm publication: - name: Checkout @@ -56,4 +56,3 @@ jobs: - name: Publish run: pnpm -r publish --access=public - diff --git a/packages/client/test/services/util.js b/packages/client/test/services/util.js index 9ec88e01..9b9f7c02 100644 --- a/packages/client/test/services/util.js +++ b/packages/client/test/services/util.js @@ -1,6 +1,6 @@ export const ok = /** @type {(...args:Args) => Args extends [T] ? {ok:true, value:T extends undefined ? null : T} : {ok:true, value:null}}} */ ( - (value) => (value == undefined ? Ok : { ok: true, value }) + value => (value == undefined ? Ok : { ok: true, value }) ) const Ok = { ok: true, value: null } @@ -9,13 +9,13 @@ const Ok = { ok: true, value: null } * @param {T} value * @returns {T} */ -export const the = (value) => value +export const the = value => value /** * @param {string} reason * @returns {never} */ -export const panic = (reason) => { +export const panic = reason => { throw new Error(reason) } diff --git a/packages/core/src/schema/did.js b/packages/core/src/schema/did.js index 6853eb25..a8d8b92b 100644 --- a/packages/core/src/schema/did.js +++ b/packages/core/src/schema/did.js @@ -68,7 +68,7 @@ class DIDBytesSchema extends Schema.API { return Schema.error(`Expected a ${prefix} but got "${did}" instead`) } else { return { ok: /** @type {API.DID} */ (did) } - } + } } } diff --git a/packages/core/test/cbor.spec.js b/packages/core/test/cbor.spec.js index bf870ae8..9110a9e9 100644 --- a/packages/core/test/cbor.spec.js +++ b/packages/core/test/cbor.spec.js @@ -69,7 +69,7 @@ test('encode / decode', async () => { const o = {} const data = { a: o, - b: o + b: o, } assert.doesNotThrow(() => transcode(data)) diff --git a/packages/core/test/delegation.spec.js b/packages/core/test/delegation.spec.js index 84576149..4b612ad4 100644 --- a/packages/core/test/delegation.spec.js +++ b/packages/core/test/delegation.spec.js @@ -437,8 +437,8 @@ test('delegation.attach block in capabiliy', async () => { can: 'store/add', with: alice.did(), nb: { - inlineBlock: block.cid.link() - } + inlineBlock: block.cid.link(), + }, }, ], }) @@ -467,8 +467,8 @@ test('delegation.attach block in facts', async () => { facts: [ { [`${block.cid.link()}`]: block.cid.link() }, // @ts-expect-error Link has fact entry - block.cid.link() - ] + block.cid.link(), + ], }) ucan.attach(block) @@ -488,11 +488,11 @@ test('delegation.attach fails to attach block with not attached link', async () capabilities: [ { can: 'store/add', - with: alice.did() + with: alice.did(), }, ], }) const block = await getBlock({ test: 'inlineBlock' }) assert.throws(() => ucan.attach(block)) -}) \ No newline at end of file +}) diff --git a/packages/core/test/extra-schema.spec.js b/packages/core/test/extra-schema.spec.js index a00bffdd..4132b18d 100644 --- a/packages/core/test/extra-schema.spec.js +++ b/packages/core/test/extra-schema.spec.js @@ -418,7 +418,11 @@ test('URI.from', () => { Uint8Array.from([1, 2, 3]), /Unable to parse bytes as did:/, ], - [{ method: 'echo' }, DIDTools.parse('did:echo:hello'), { ok: 'did:echo:hello' }], + [ + { method: 'echo' }, + DIDTools.parse('did:echo:hello'), + { ok: 'did:echo:hello' }, + ], [ { method: 'foo' }, DIDTools.parse('did:echo:hello'), @@ -500,11 +504,16 @@ test('URI.from', () => { [undefined, /Expected value of type Uint8Array instead got undefined/], [null, /Expected value of type Uint8Array instead got null/], [Uint8Array.from([1, 2, 3]), /Unable to decode bytes as DID:/], - [DIDTools.parse('did:echo:1'), { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 49]) }], + [ + DIDTools.parse('did:echo:1'), + { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 49]) }, + ], ] for (const [input, out] of dataset) { - test(`Principal.read(${input == null ? input : `Uint8Array([${input}])`})`, () => { + test(`Principal.read(${ + input == null ? input : `Uint8Array([${input}])` + })`, () => { matchResult(Principal.read(input), out) }) } @@ -531,7 +540,11 @@ test('URI.from', () => { [ { method: 'echo' }, DIDTools.parse('did:echo:hello'), - { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 104, 101, 108, 108, 111]) } + { + ok: new Uint8Array([ + 157, 26, 101, 99, 104, 111, 58, 104, 101, 108, 108, 111, + ]), + }, ], [ { method: 'foo' }, @@ -541,7 +554,9 @@ test('URI.from', () => { ] for (const [options, input, out] of dataset) { - test(`Principal.match({ method: ${options.method == null ? options.method : `"${options.method}"`} }).read(${input == null ? input : `Uint8Array([${input}])`})`, () => { + test(`Principal.match({ method: ${ + options.method == null ? options.method : `"${options.method}"` + } }).read(${input == null ? input : `Uint8Array([${input}])`})`, () => { matchResult(Principal.match(options).read(input), out) }) } @@ -555,7 +570,7 @@ test('URI.from', () => { [ {}, DIDTools.parse('did:echo:bar'), - { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 98, 97, 114]) } + { ok: new Uint8Array([157, 26, 101, 99, 104, 111, 58, 98, 97, 114]) }, ], [{ method: 'echo' }, undefined, { ok: undefined }], [ @@ -576,8 +591,14 @@ test('URI.from', () => { ] for (const [options, input, out] of dataset) { - test(`Principal.match({ method: ${options.method == null ? options.method : `"${options.method}"`} }).optional().read(${input == null ? input : `Uint8Array([${input}])`})`, () => { - const schema = options.method ? Principal.match(options) : Principal.principal() + test(`Principal.match({ method: ${ + options.method == null ? options.method : `"${options.method}"` + } }).optional().read(${ + input == null ? input : `Uint8Array([${input}])` + })`, () => { + const schema = options.method + ? Principal.match(options) + : Principal.principal() matchResult(schema.optional().read(input), out) }) } @@ -596,7 +617,9 @@ test('URI.from', () => { ], ] for (const [did, errorExpectation] of dataset) { - test(`Principal.from(${did == null ? did : `Uint8Array([${did}])`})`, () => { + test(`Principal.from(${ + did == null ? did : `Uint8Array([${did}])` + })`, () => { let error try { Principal.from(did) diff --git a/packages/core/test/utils.js b/packages/core/test/utils.js index e0d105ba..7324a739 100644 --- a/packages/core/test/utils.js +++ b/packages/core/test/utils.js @@ -9,6 +9,6 @@ export async function getBlock(value) { return await Block.encode({ value, codec, - hasher + hasher, }) } diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index c4b7b905..3fa6ba18 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -45,7 +45,7 @@ import { Revoked, InferCapability, Authorization, - Reader + Reader, } from './capability.js' import type * as Transport from './transport.js' import type { Tuple, Block } from './transport.js' @@ -978,7 +978,9 @@ export interface HTTPError { /** * Options for UCAN validation. */ -export interface ValidatorOptions extends PrincipalResolver, Partial { +export interface ValidatorOptions + extends PrincipalResolver, + Partial { /** * Schema allowing invocations to be accepted for audiences other than the * service itself. @@ -1092,7 +1094,7 @@ export interface PrincipalParser { */ export interface PrincipalResolver { resolveDIDKey?: ( - did: UCAN.DID, + did: UCAN.DID ) => Await> } diff --git a/packages/server/src/handler.js b/packages/server/src/handler.js index 2d97ffc3..1accd141 100644 --- a/packages/server/src/handler.js +++ b/packages/server/src/handler.js @@ -52,7 +52,8 @@ export const provideAdvanced = // If audience schema is not provided we expect the audience to match // the server id. Users could pass `schema.string()` if they want to accept // any audience. - const audienceSchema = audience || options.audience || Schema.literal(options.id.did()) + const audienceSchema = + audience || options.audience || Schema.literal(options.id.did()) const result = audienceSchema.read(invocation.audience.did()) if (result.error) { return { error: new InvalidAudience({ cause: result.error }) } diff --git a/packages/server/src/server.js b/packages/server/src/server.js index 43e71a0b..d7a06e46 100644 --- a/packages/server/src/server.js +++ b/packages/server/src/server.js @@ -79,15 +79,18 @@ export const handle = async (server, request) => { } } else { const { encoder, decoder } = selection.ok - let message; + let message try { message = await decoder.decode(request) } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unable to decode request' + const errorMessage = + err instanceof Error ? err.message : 'Unable to decode request' return { status: 400, headers: { 'Content-Type': 'text/plain' }, - body: new TextEncoder().encode(`Bad request: Malformed payload - ${errorMessage}`), + body: new TextEncoder().encode( + `Bad request: Malformed payload - ${errorMessage}` + ), } } const result = await execute(message, server) @@ -198,4 +201,4 @@ export const resolve = (service, path) => { } } return target -} \ No newline at end of file +} diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index a516d78a..b9f032af 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -350,9 +350,10 @@ test('did:web principal resolve', async () => { }, codec: CAR.inbound, id: w3, - resolveDIDKey: did => did === account.did() - ? Server.ok([bob.did()]) - : Server.error(new DIDResolutionError(did)), + resolveDIDKey: did => + did === account.did() + ? Server.ok([bob.did()]) + : Server.error(new DIDResolutionError(did)), validateAuthorization: () => ({ ok: {} }), }) @@ -404,7 +405,7 @@ test('alternative audience', async () => { id: service, audience: Schema.or( Schema.literal('did:web:web3.storage'), - Schema.literal(alias.did()), + Schema.literal(alias.did()) ), validateAuthorization: () => ({ ok: {} }), }) @@ -606,9 +607,9 @@ test('should return 400 Bad Request for malformed payloads', async () => { const malformedRequest = { headers: { 'content-type': CAR.contentType, - 'accept': CAR.contentType + accept: CAR.contentType, }, - body: new Uint8Array([1, 2, 3]) + body: new Uint8Array([1, 2, 3]), } const response = await server.request(malformedRequest) @@ -630,10 +631,10 @@ test('should return 400 Bad Request for non-Error decoder failures', async () => decoder: { decode: async () => { throw 'Not an Error instance' - } - } - } - }) + }, + }, + }, + }), }, validateAuthorization: () => ({ ok: {} }), }) @@ -641,9 +642,9 @@ test('should return 400 Bad Request for non-Error decoder failures', async () => const malformedRequest = { headers: { 'content-type': CAR.contentType, - 'accept': CAR.contentType + accept: CAR.contentType, }, - body: new Uint8Array([1, 2, 3]) + body: new Uint8Array([1, 2, 3]), } const response = await server.request(malformedRequest) @@ -651,5 +652,8 @@ test('should return 400 Bad Request for non-Error decoder failures', async () => assert.equal(response.status, 400) assert.deepEqual(response.headers, { 'Content-Type': 'text/plain' }) const errorMessage = new TextDecoder().decode(response.body) - assert.match(errorMessage, /Bad request: Malformed payload - Unable to decode request/) + assert.match( + errorMessage, + /Bad request: Malformed payload - Unable to decode request/ + ) }) diff --git a/packages/transport/src/http.js b/packages/transport/src/http.js index b4df7730..5cfce071 100644 --- a/packages/transport/src/http.js +++ b/packages/transport/src/http.js @@ -68,7 +68,10 @@ class Channel { const buffer = response.ok ? await response.arrayBuffer() - : HTTPError.throw(`HTTP Request failed. ${this.method} ${this.url.href} → ${response.status}`, response) + : HTTPError.throw( + `HTTP Request failed. ${this.method} ${this.url.href} → ${response.status}`, + response + ) return { headers: response.headers.entries diff --git a/packages/transport/test/https.spec.js b/packages/transport/test/https.spec.js index fb93c308..0f17f695 100644 --- a/packages/transport/test/https.spec.js +++ b/packages/transport/test/https.spec.js @@ -100,8 +100,11 @@ test('headers from http channel are passed to fetch along with the request heade headers: { 'x-client': 'abc' }, }) - const requestHeaders = { 'x-test': 'test-value', 'content-type': 'text/plain' } - + const requestHeaders = { + 'x-test': 'test-value', + 'content-type': 'text/plain', + } + await channel.request({ headers: requestHeaders, body: UTF8.encode('ping'), diff --git a/packages/transport/test/util.js b/packages/transport/test/util.js index 60df4a58..9c805589 100644 --- a/packages/transport/test/util.js +++ b/packages/transport/test/util.js @@ -3,7 +3,7 @@ * @param {AsyncIterable|Iterable} iterable * @returns {Promise} */ -export const collect = async (iterable) => { +export const collect = async iterable => { const result = [] for await (const item of iterable) { result.push(item) diff --git a/packages/validator/src/error.js b/packages/validator/src/error.js index 16b26762..642cb419 100644 --- a/packages/validator/src/error.js +++ b/packages/validator/src/error.js @@ -334,7 +334,7 @@ export class Unauthorized extends Failure { failedProofs, }) { super() - this.name = /** @type {const} */ ('Unauthorized') + this.name = /** @type {const} */ ('Unauthorized') this.capability = capability this.delegationErrors = delegationErrors this.unknownCapabilities = unknownCapabilities diff --git a/packages/validator/src/lib.js b/packages/validator/src/lib.js index 353d96cf..d3b0b4c1 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -108,7 +108,7 @@ const resolveProofs = async (proofs, config) => { } } catch (error) { errors.push( - new UnavailableProof(proof, /** @type {Error} */(error)) + new UnavailableProof(proof, /** @type {Error} */ (error)) ) } @@ -175,7 +175,7 @@ const resolveSources = async ({ delegation }, config) => { // track which proof in which capability the are from. for (const capability of proof.capabilities) { sources.push( - /** @type {API.Source} */({ + /** @type {API.Source} */ ({ capability, delegation: proof, }) @@ -263,7 +263,7 @@ export const claim = async ( if (validation.ok) { for (const capability of validation.ok.capabilities.values()) { sources.push( - /** @type {API.Source} */({ + /** @type {API.Source} */ ({ capability, delegation: validation.ok, }) @@ -450,7 +450,7 @@ const validate = async (delegation, proofs, config) => { if (UCAN.isExpired(delegation.data)) { return { error: new Expired( - /** @type {API.Delegation & {expiration: number}} */(delegation) + /** @type {API.Delegation & {expiration: number}} */ (delegation) ), } } @@ -458,7 +458,7 @@ const validate = async (delegation, proofs, config) => { if (UCAN.isTooEarly(delegation.data)) { return { error: new NotValidBefore( - /** @type {API.Delegation & {notBefore: number}} */(delegation) + /** @type {API.Delegation & {notBefore: number}} */ (delegation) ), } } @@ -525,7 +525,7 @@ const verifyAuthorization = async (delegation, proofs, config) => { verificationErrResults.push(verificationResult.error) } } - + // If no verifiers were found, there is no way to verify the signature if (verificationErrResults.length === 0) { return { error: new DIDKeyResolutionError(issuer) } @@ -535,7 +535,7 @@ const verifyAuthorization = async (delegation, proofs, config) => { const combinedMessage = verificationErrResults .map(err => err.message) .join('\n ') - + // @ts-expect-error - both error types have describe method, override it to return the concatenated message combinedError.describe = () => combinedMessage diff --git a/packages/validator/test/session.spec.js b/packages/validator/test/session.spec.js index efdeb32d..b2e2f69a 100644 --- a/packages/validator/test/session.spec.js +++ b/packages/validator/test/session.spec.js @@ -130,8 +130,8 @@ test('validate mailto attested by another service', async () => { await attest.delegate({ issuer: w3, audience: other, - with: w3.did() - }) + with: w3.did(), + }), ], }) @@ -405,7 +405,7 @@ test('fail unknown ucan/attest proof', async () => { return Schema.ok([otherService.toDIDKey()]) } return { error: new DIDKeyResolutionError(did) } - } + }, }) assert.containSubset(result, { @@ -660,7 +660,7 @@ test('fail when no verifiers found', async () => { capability: echo, principal: Verifier, validateAuthorization: () => ({ ok: {} }), - resolveDIDKey: () => ({ ok: [] }) + resolveDIDKey: () => ({ ok: [] }), }) assert.match( @@ -702,7 +702,7 @@ test('succeed with single valid verifier', async () => { capability: echo, principal: Verifier, validateAuthorization: () => ({ ok: {} }), - resolveDIDKey: () => ({ ok: [alice.toDIDKey()] }) + resolveDIDKey: () => ({ ok: [alice.toDIDKey()] }), }) assert.ok(result.ok) @@ -742,7 +742,12 @@ test('succeed with multiple verifiers and one valid', async () => { capability: echo, principal: Verifier, validateAuthorization: () => ({ ok: {} }), - resolveDIDKey: () => ({ ok: [`did:key:${other.did().split(':')[2]}`, `did:key:${alice.did().split(':')[2]}`] }) + resolveDIDKey: () => ({ + ok: [ + `did:key:${other.did().split(':')[2]}`, + `did:key:${alice.did().split(':')[2]}`, + ], + }), }) assert.ok(result.ok) @@ -776,13 +781,13 @@ test('fail with multiple invalid verifiers', async () => { capability: echo, principal: Verifier, validateAuthorization: () => ({ ok: {} }), - resolveDIDKey: (did) => { + resolveDIDKey: did => { if (did === account.did()) { // Return verifiers that don't match the account's actual key return { ok: [other1.toDIDKey(), other2.toDIDKey()] } } return { error: new DIDKeyResolutionError(did) } - } + }, }) console.log('Result:', result) @@ -792,4 +797,4 @@ test('fail with multiple invalid verifiers', async () => { `${result.error}`, /Proof .* does not has a valid signature from did:key:/ ) -}) \ No newline at end of file +}) From 0d96d5ce937af19c7b39d8c6bab870b2f578155c Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 12:26:32 +0100 Subject: [PATCH 3/5] refactor(principal): remove noble ed25519 signer path --- packages/principal/package.json | 1 - packages/principal/src/ed25519/signer.js | 125 +++++++++++++++++++---- packages/principal/test/ed25519.spec.js | 83 +++++++++++++++ pnpm-lock.yaml | 11 +- 4 files changed, 187 insertions(+), 33 deletions(-) diff --git a/packages/principal/package.json b/packages/principal/package.json index 69396ce4..c58f4a71 100644 --- a/packages/principal/package.json +++ b/packages/principal/package.json @@ -29,7 +29,6 @@ "dependencies": { "@ipld/dag-ucan": "^3.4.5", "@noble/curves": "^1.2.0", - "@noble/ed25519": "^1.7.3", "@noble/hashes": "^1.3.2", "@ucanto/interface": "workspace:^", "multiformats": "^13.3.1", diff --git a/packages/principal/src/ed25519/signer.js b/packages/principal/src/ed25519/signer.js index 235746b3..1f181d80 100644 --- a/packages/principal/src/ed25519/signer.js +++ b/packages/principal/src/ed25519/signer.js @@ -1,9 +1,8 @@ -import * as ED25519 from '@noble/ed25519' import { webcrypto } from 'one-webcrypto' import { varint } from 'multiformats' import * as API from './type.js' import * as Verifier from './verifier.js' -import { base64pad } from 'multiformats/bases/base64' +import { base64pad, base64url } from 'multiformats/bases/base64' import * as Signature from '@ipld/dag-ucan/signature' import * as Signer from '../signer.js' export * from './type.js' @@ -19,6 +18,11 @@ const PRIVATE_TAG_SIZE = varint.encodingLength(code) const PUBLIC_TAG_SIZE = varint.encodingLength(Verifier.code) const KEY_SIZE = 32 const SIZE = PRIVATE_TAG_SIZE + KEY_SIZE + PUBLIC_TAG_SIZE + KEY_SIZE +const ALG = { name: 'Ed25519' } +const PKCS8_PREFIX = Uint8Array.from([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, + 0x22, 0x04, 0x20, +]) export const PUB_KEY_OFFSET = PRIVATE_TAG_SIZE + KEY_SIZE @@ -28,17 +32,21 @@ export const PUB_KEY_OFFSET = PRIVATE_TAG_SIZE + KEY_SIZE * @returns {Promise} */ export const generate = async ({ extractable = false } = {}) => { - if (extractable) { - return derive(ED25519.utils.randomPrivateKey()) - } - const keypair = /** @type {CryptoKeyPair} */ ( - await webcrypto.subtle.generateKey({ name: 'Ed25519' }, false, [ - 'sign', - 'verify', - ]) + await webcrypto.subtle.generateKey(ALG, extractable, ['sign', 'verify']) ) + if (extractable) { + const pkcs8 = new Uint8Array( + await webcrypto.subtle.exportKey('pkcs8', keypair.privateKey) + ) + const secret = decodePKCS8(pkcs8) + const publicKey = new Uint8Array( + await webcrypto.subtle.exportKey('raw', keypair.publicKey) + ) + return createSigner({ secret, publicKey }) + } + const raw = new Uint8Array( await webcrypto.subtle.exportKey('raw', keypair.publicKey) ) @@ -64,16 +72,16 @@ export const derive = async secret => { ) } - const publicKey = await ED25519.getPublicKey(secret) - const signer = new Ed25519Signer(SIZE) - - varint.encodeTo(code, signer, 0) - signer.set(secret, PRIVATE_TAG_SIZE) - - varint.encodeTo(Verifier.code, signer, PRIVATE_TAG_SIZE + KEY_SIZE) - signer.set(publicKey, PRIVATE_TAG_SIZE + KEY_SIZE + PUBLIC_TAG_SIZE) - - return signer + const privateKey = await webcrypto.subtle.importKey( + 'pkcs8', + encodePKCS8(secret), + ALG, + true, + ['sign'] + ) + const jwk = await webcrypto.subtle.exportKey('jwk', privateKey) + const publicKey = decodePublicKey(jwk) + return createSigner({ secret, publicKey }) } /** @@ -225,7 +233,18 @@ class Ed25519Signer extends Uint8Array { * @returns {Promise>} */ async sign(payload) { - const raw = await ED25519.sign(payload, this.secret) + const state = /** @type {{privateKey?: Promise}} */ (this) + const privateKey = + state.privateKey || + webcrypto.subtle.importKey('pkcs8', encodePKCS8(this.secret), ALG, true, [ + 'sign', + ]) + + state.privateKey = privateKey + + const raw = new Uint8Array( + await webcrypto.subtle.sign(ALG, await privateKey, payload) + ) return Signature.create(this.signatureCode, raw) } @@ -306,7 +325,7 @@ class UnextractableEd25519Signer { */ async sign(payload) { const raw = new Uint8Array( - await webcrypto.subtle.sign({ name: 'Ed25519' }, this.privateKey, payload) + await webcrypto.subtle.sign(ALG, this.privateKey, payload) ) return Signature.create(this.signatureCode, raw) } @@ -343,3 +362,65 @@ class UnextractableEd25519Signer { } } } + +/** + * @param {object} options + * @param {Uint8Array} options.secret + * @param {Uint8Array} options.publicKey + */ +const createSigner = ({ secret, publicKey }) => { + const signer = new Ed25519Signer(SIZE) + + varint.encodeTo(code, signer, 0) + signer.set(secret, PRIVATE_TAG_SIZE) + + varint.encodeTo(Verifier.code, signer, PRIVATE_TAG_SIZE + KEY_SIZE) + signer.set(publicKey, PRIVATE_TAG_SIZE + KEY_SIZE + PUBLIC_TAG_SIZE) + + return signer +} + +/** + * @param {Uint8Array} secret + */ +const encodePKCS8 = secret => { + const bytes = new Uint8Array(PKCS8_PREFIX.length + KEY_SIZE) + bytes.set(PKCS8_PREFIX, 0) + bytes.set(secret, PKCS8_PREFIX.length) + return bytes +} + +/** + * @param {Uint8Array} pkcs8 + */ +const decodePKCS8 = pkcs8 => { + if (pkcs8.byteLength !== PKCS8_PREFIX.length + KEY_SIZE) { + throw new TypeError('Unsupported ed25519 pkcs8 key length') + } + + for (let i = 0; i < PKCS8_PREFIX.length; i += 1) { + if (pkcs8[i] !== PKCS8_PREFIX[i]) { + throw new TypeError('Unsupported ed25519 pkcs8 key format') + } + } + + return pkcs8.subarray(PKCS8_PREFIX.length) +} + +/** + * @param {JsonWebKey} jwk + */ +const decodePublicKey = jwk => { + if (typeof jwk.x !== 'string') { + throw new TypeError('Can not derive ed25519 public key from JWK') + } + + const bytes = base64url.baseDecode(jwk.x) + if (bytes.byteLength !== KEY_SIZE) { + throw new TypeError( + `Expected JWK public key with byteLength ${KEY_SIZE} instead not ${bytes.byteLength}` + ) + } + + return bytes +} diff --git a/packages/principal/test/ed25519.spec.js b/packages/principal/test/ed25519.spec.js index b818e788..5d75f7ac 100644 --- a/packages/principal/test/ed25519.spec.js +++ b/packages/principal/test/ed25519.spec.js @@ -2,6 +2,7 @@ import { ed25519, ed25519 as Lib } from '../src/lib.js' import { assert } from 'chai' import { sha256 } from 'multiformats/hashes/sha2' import { varint } from 'multiformats' +import { base64url } from 'multiformats/bases/base64' import { webcrypto } from 'one-webcrypto' describe('signing principal', () => { @@ -135,6 +136,88 @@ describe('signing principal', () => { assert.equal(signer.signatureAlgorithm, 'EdDSA') assert.equal(await alias.verify(payload, signature), true) }) + + it('generate extractable throws on unsupported pkcs8 key length', async () => { + const exportKey = /** @type {any} */ (webcrypto.subtle.exportKey) + webcrypto.subtle.exportKey = async function (format, key) { + if (format === 'pkcs8') { + return new Uint8Array(47) + } + return exportKey.call(this, format, key) + } + + try { + await Lib.generate({ extractable: true }) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Unsupported ed25519 pkcs8 key length/) + } finally { + webcrypto.subtle.exportKey = + /** @type {typeof webcrypto.subtle.exportKey} */ (exportKey) + } + }) + + it('generate extractable throws on unsupported pkcs8 key format', async () => { + const exportKey = /** @type {any} */ (webcrypto.subtle.exportKey) + webcrypto.subtle.exportKey = async function (format, key) { + if (format === 'pkcs8') { + const pkcs8 = new Uint8Array(await exportKey.call(this, format, key)) + pkcs8[0] = 0x00 + return pkcs8 + } + return exportKey.call(this, format, key) + } + + try { + await Lib.generate({ extractable: true }) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Unsupported ed25519 pkcs8 key format/) + } finally { + webcrypto.subtle.exportKey = + /** @type {typeof webcrypto.subtle.exportKey} */ (exportKey) + } + }) + + it('derive throws when JWK has no x', async () => { + const exportKey = /** @type {any} */ (webcrypto.subtle.exportKey) + webcrypto.subtle.exportKey = async function (format, key) { + if (format === 'jwk') { + return {} + } + return exportKey.call(this, format, key) + } + + try { + await Lib.derive(new Uint8Array(32)) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Can not derive ed25519 public key from JWK/) + } finally { + webcrypto.subtle.exportKey = + /** @type {typeof webcrypto.subtle.exportKey} */ (exportKey) + } + }) + + it('derive throws when JWK x has invalid size', async () => { + const exportKey = /** @type {any} */ (webcrypto.subtle.exportKey) + webcrypto.subtle.exportKey = async function (format, key) { + if (format === 'jwk') { + return { x: base64url.baseEncode(new Uint8Array(31)) } + } + return exportKey.call(this, format, key) + } + + try { + await Lib.derive(new Uint8Array(32)) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Expected JWK public key with byteLength 32/) + } finally { + webcrypto.subtle.exportKey = + /** @type {typeof webcrypto.subtle.exportKey} */ (exportKey) + } + }) }) describe('principal', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66542d75..666be282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,9 +131,6 @@ importers: '@noble/curves': specifier: ^1.2.0 version: 1.8.1 - '@noble/ed25519': - specifier: ^1.7.3 - version: 1.7.3 '@noble/hashes': specifier: ^1.3.2 version: 1.7.1 @@ -586,9 +583,6 @@ packages: resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==} engines: {node: ^14.21.3 || >=16} - '@noble/ed25519@1.7.3': - resolution: {integrity: sha512-iR8GBkDt0Q3GyaVcIu7mSsVIqnFbkbRzGLWlvhwunacoLwt4J3swfKhfaM6rN6WY+TBGoYT1GtT1mIh2/jGbRQ==} - '@noble/hashes@1.7.1': resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} engines: {node: ^14.21.3 || >=16} @@ -793,6 +787,7 @@ packages: chai-subset@1.6.0: resolution: {integrity: sha512-K3d+KmqdS5XKW5DWPd5sgNffL3uxdDe+6GdnJh3AYPhwnBGRY5urfvfcbRtWIvvpz+KxkL9FeBB6MZewLUNwug==} engines: {node: '>=4'} + deprecated: 'functionality of this lib is built-in to chai now. see more details here: https://github.com/debitoor/chai-subset/pull/85' chai@4.5.0: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} @@ -2318,8 +2313,6 @@ snapshots: dependencies: '@noble/hashes': 1.7.1 - '@noble/ed25519@1.7.3': {} - '@noble/hashes@1.7.1': {} '@nodelib/fs.scandir@2.1.5': @@ -3937,8 +3930,6 @@ snapshots: typescript@5.0.4: {} - typescript@5.7.3: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.3 From f5d2b8ff06e9abd4d8e60c5f3b8e12e24b0b0a32 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 12:26:39 +0100 Subject: [PATCH 4/5] test(server): assert receipt issuer by did in execution error case --- packages/server/test/server.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index b9f032af..7e3ce473 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -280,9 +280,9 @@ test('execution error', async () => { }) const receipt = await boom.execute(connection) + assert.equal(receipt.issuer.did(), w3.did()) assert.containSubset(receipt, { - issuer: w3.verifier, out: { error: { error: true, From 169bc4ac9df967c2866b5a353d0728b8446d9840 Mon Sep 17 00:00:00 2001 From: Nico Krause Date: Wed, 11 Feb 2026 16:57:06 +0100 Subject: [PATCH 5/5] docs: align README snippets with tested API across packages --- Readme.md | 15 ++-- packages/client/README.md | 9 +- .../test/client-readme-snippets.spec.js | 73 ++++++++++++++++ packages/core/README.md | 24 ++++-- .../core/test/core-readme-snippets.spec.js | 18 ++++ packages/principal/README.md | 12 ++- .../test/principal-readme-snippets.spec.js | 20 +++++ packages/server/README.md | 4 +- ...c.js => server-readme-integration.spec.js} | 0 ...spec.js => server-readme-snippets.spec.js} | 2 +- packages/transport/README.md | 11 ++- .../test/transport-readme-snippets.spec.js | 84 +++++++++++++++++++ packages/validator/README.md | 17 ++-- .../test/validator-readme-snippets.spec.js | 51 +++++++++++ 14 files changed, 305 insertions(+), 35 deletions(-) create mode 100644 packages/client/test/client-readme-snippets.spec.js create mode 100644 packages/core/test/core-readme-snippets.spec.js create mode 100644 packages/principal/test/principal-readme-snippets.spec.js rename packages/server/test/{readme-integration.spec.js => server-readme-integration.spec.js} (100%) rename packages/server/test/{readme-examples.spec.js => server-readme-snippets.spec.js} (97%) create mode 100644 packages/transport/test/transport-readme-snippets.spec.js create mode 100644 packages/validator/test/validator-readme-snippets.spec.js diff --git a/Readme.md b/Readme.md index 3511b025..1cdd37d6 100644 --- a/Readme.md +++ b/Readme.md @@ -113,8 +113,9 @@ const result = await invocation.execute(connection) ``` > 📝 **Tested in**: + > - [`packages/client/test/client.spec.js:70`](./packages/client/test/client.spec.js#L70) - Delegation creation and usage -> - [`packages/server/test/readme-integration.spec.js:160`](./packages/server/test/readme-integration.spec.js#L160) - Delegation with server validation +> - [`packages/server/test/server-readme-integration.spec.js:160`](./packages/server/test/server-readme-integration.spec.js#L160) - Delegation with server validation ### Batch Operations @@ -205,7 +206,7 @@ This demonstrates how UCAN's delegation system provides fine-grained access cont - ❌ **Mallory fails** - Bob doesn't have permission for Mallory's namespace - 🔒 **Security** - The service validates the delegation chain and resource ownership -> 📝 **Tested in**: [`packages/server/test/readme-integration.spec.js:99`](./packages/server/test/readme-integration.spec.js#L99) - Advanced delegation patterns with namespace validation +> 📝 **Tested in**: [`packages/server/test/server-readme-integration.spec.js:99`](./packages/server/test/server-readme-integration.spec.js#L99) - Advanced delegation patterns with namespace validation ## Service-Specific Examples @@ -246,24 +247,24 @@ const connection = Client.connect({ import { ed25519 } from '@ucanto/principal' // Generate new keys -const agent = await ed25519.generate() +const agent = await ed25519.generate({ extractable: true }) // Save keys (browser) -localStorage.setItem('agent', agent.toString()) +localStorage.setItem('agent', ed25519.format(agent)) // Load keys (browser) const savedAgent = ed25519.parse(localStorage.getItem('agent')) // Save keys (Node.js) import fs from 'fs/promises' -await fs.writeFile('agent.key', agent.toString()) +await fs.writeFile('agent.key', ed25519.format(agent)) // Load keys (Node.js) const keyData = await fs.readFile('agent.key', 'utf-8') const loadedAgent = ed25519.parse(keyData) ``` -> 📝 **Tested in**: [`packages/server/test/readme-examples.spec.js:54`](./packages/server/test/readme-examples.spec.js#L54) - Key generation, formatting, and parsing +> 📝 **Tested in**: [`packages/server/test/server-readme-snippets.spec.js:54`](./packages/server/test/server-readme-snippets.spec.js#L54) - Key generation, formatting, and parsing ## Package Overview @@ -283,4 +284,4 @@ const loadedAgent = ed25519.parse(keyData) [car]: https://ipld.io/specs/transport/car/carv1/ [dag-cbor]: https://ipld.io/specs/codecs/dag-cbor/ [cid]: https://docs.ipfs.io/concepts/content-addressing/ -[did:key]: https://w3c-ccg.github.io/did-method-key/ \ No newline at end of file +[did:key]: https://w3c-ccg.github.io/did-method-key/ diff --git a/packages/client/README.md b/packages/client/README.md index 5b9cdae2..e6bca67f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -66,6 +66,8 @@ const [receipt] = await connection.execute(invocation) console.log(receipt.out.error ? 'Failed:' : 'Success:', receipt.out) ``` +> 📝 **Tested in**: [`client-readme-snippets.spec.js`](./test/client-readme-snippets.spec.js) + ### Using Server as Channel (for Testing) For testing or local development, you can use a UCAN server directly as a channel without HTTP. See the [`@ucanto/server` README](../server/README.md) for details on setting up a server. @@ -85,7 +87,8 @@ Create a file called `generate-keys.js`: import { ed25519 } from '@ucanto/principal' async function generateKeys() { - const keypair = await ed25519.generate() + // Use extractable keys if you need to serialize for env vars. + const keypair = await ed25519.generate({ extractable: true }) const privateKey = ed25519.format(keypair) @@ -95,6 +98,8 @@ async function generateKeys() { generateKeys().catch(console.error) ``` +> 📝 **Tested in**: [`client-readme-snippets.spec.js`](./test/client-readme-snippets.spec.js) + Then run it: ```bash @@ -123,4 +128,4 @@ SERVICE_DID="did:key:service_provider_did_here" \ ``` -For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file +For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/client/test/client-readme-snippets.spec.js b/packages/client/test/client-readme-snippets.spec.js new file mode 100644 index 00000000..d4027175 --- /dev/null +++ b/packages/client/test/client-readme-snippets.spec.js @@ -0,0 +1,73 @@ +import { test, assert } from './test.js' +import * as Client from '../src/lib.js' +import * as HTTP from '@ucanto/transport/http' +import { CAR } from '@ucanto/transport' +import { ed25519 } from '@ucanto/principal' +import { DID, Message, Receipt } from '@ucanto/core' + +test('README connection and invocation example works', async () => { + const serviceSigner = ed25519.parse( + 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' + ) + const service = DID.parse(serviceSigner.did()) + const issuer = ed25519.parse( + 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' + ) + + const channel = HTTP.open({ + url: new URL('about:blank'), + fetch: async (url, init) => { + assert.equal(url, 'about:blank') + const request = await CAR.request.decode({ + headers: /** @type {Record} */ (init.headers), + body: /** @type {Uint8Array} */ (init.body), + }) + const [invocation] = request.invocations + + const receipt = await Receipt.issue({ + issuer: serviceSigner, + ran: invocation.link(), + result: { ok: { accepted: true } }, + }) + const response = await CAR.response.encode( + await Message.build({ receipts: [receipt] }) + ) + + return { + ok: true, + arrayBuffer: () => response.body.buffer, + headers: new Map([['content-type', CAR.contentType]]), + } + }, + }) + + const connection = Client.connect({ + id: service, + channel, + codec: CAR.outbound, + }) + + const invocation = Client.invoke({ + issuer, + audience: service, + capability: { + can: 'store/add', + with: issuer.did(), + nb: { + link: 'bafybeigwflfnv7tjgpuy52ep45cbbgkkb2makd3bwhbj3ueabvt3eq43ca', + }, + }, + }) + + const [receipt] = await connection.execute(invocation) + assert.ok(!receipt.out.error, `Expected no error, got: ${receipt.out.error}`) + assert.deepEqual(receipt.out.ok, { accepted: true }) +}) + +test('README AGENT_PRIVATE_KEY generation snippet works', async () => { + const keypair = await ed25519.generate({ extractable: true }) + const privateKey = ed25519.format(keypair) + const parsed = ed25519.parse(privateKey) + + assert.equal(parsed.did(), keypair.did()) +}) diff --git a/packages/core/README.md b/packages/core/README.md index ad327e0c..b65621cc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -22,13 +22,21 @@ npm install @ucanto/core ## Example Usage ```ts -import { capability, URI, Link } from '@ucanto/core'; - -const AddFile = capability({ - can: 'file/add', - with: URI.match({ protocol: 'file:' }), - nb: { link: Link } -}); +import { Schema, parseLink } from '@ucanto/core'; + +const AddFile = Schema.struct({ + with: Schema.uri({ protocol: 'file:' }), + nb: Schema.struct({ + link: Schema.link(), + }), +}) + +const parsed = AddFile.read({ + with: 'file:///tmp/example.txt', + nb: { link: parseLink('bafkqaaa') }, +}) ``` -For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file +> 📝 **Tested in**: [`core-readme-snippets.spec.js`](./test/core-readme-snippets.spec.js) + +For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/core/test/core-readme-snippets.spec.js b/packages/core/test/core-readme-snippets.spec.js new file mode 100644 index 00000000..eec73362 --- /dev/null +++ b/packages/core/test/core-readme-snippets.spec.js @@ -0,0 +1,18 @@ +import { test, assert } from './test.js' +import { Schema, parseLink } from '../src/lib.js' + +test('README schema example works', async () => { + const AddFile = Schema.struct({ + with: Schema.uri({ protocol: 'file:' }), + nb: Schema.struct({ + link: Schema.link(), + }), + }) + + const result = AddFile.read({ + with: 'file:///tmp/example.txt', + nb: { link: parseLink('bafkqaaa') }, + }) + + assert.ok(!result.error, `Expected no error, got: ${result.error?.message}`) +}) diff --git a/packages/principal/README.md b/packages/principal/README.md index 01a85b6a..c5e776f3 100644 --- a/packages/principal/README.md +++ b/packages/principal/README.md @@ -24,9 +24,15 @@ npm install @ucanto/principal ```ts import { ed25519 } from '@ucanto/principal'; -const keypair = ed25519.generate(); -const signature = keypair.sign(new Uint8Array([1, 2, 3])); -const isValid = keypair.verify(new Uint8Array([1, 2, 3]), signature); +const keypair = await ed25519.generate(); +const signature = await keypair.sign(new Uint8Array([1, 2, 3])); +const isValid = await keypair.verify(new Uint8Array([1, 2, 3]), signature); + +// If you need to serialize a private key, generate extractable keys: +const exportable = await ed25519.generate({ extractable: true }); +const privateKey = ed25519.format(exportable); ``` +> 📝 **Tested in**: [`principal-readme-snippets.spec.js`](./test/principal-readme-snippets.spec.js) + For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/principal/test/principal-readme-snippets.spec.js b/packages/principal/test/principal-readme-snippets.spec.js new file mode 100644 index 00000000..1d7fb493 --- /dev/null +++ b/packages/principal/test/principal-readme-snippets.spec.js @@ -0,0 +1,20 @@ +import { assert } from 'chai' +import { ed25519 } from '../src/lib.js' + +describe('principal README snippets', () => { + it('key generation, sign, verify, and serialization example works', async () => { + const payload = new Uint8Array([1, 2, 3]) + + const keypair = await ed25519.generate() + const signature = await keypair.sign(payload) + const isValid = await keypair.verify(payload, signature) + + assert.equal(isValid, true) + + const exportable = await ed25519.generate({ extractable: true }) + const privateKey = ed25519.format(exportable) + const parsed = ed25519.parse(privateKey) + + assert.equal(parsed.did(), exportable.did()) + }) +}) diff --git a/packages/server/README.md b/packages/server/README.md index 483141c4..9f56d518 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -225,7 +225,7 @@ const aboutBob = Client.invoke({ This example is tested in the integration tests: -- **Complete workflow test**: [`readme-integration.spec.js:19`](../test/readme-integration.spec.js#L19) - End-to-end integration test -- **Component tests**: [`readme-examples.spec.js:11`](../test/readme-examples.spec.js#L11) - Individual capability and service tests +- **Complete workflow test**: [`server-readme-integration.spec.js:19`](./test/server-readme-integration.spec.js#L19) - End-to-end integration test +- **Component tests**: [`server-readme-snippets.spec.js:11`](./test/server-readme-snippets.spec.js#L11) - Individual capability and service tests For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/server/test/readme-integration.spec.js b/packages/server/test/server-readme-integration.spec.js similarity index 100% rename from packages/server/test/readme-integration.spec.js rename to packages/server/test/server-readme-integration.spec.js diff --git a/packages/server/test/readme-examples.spec.js b/packages/server/test/server-readme-snippets.spec.js similarity index 97% rename from packages/server/test/readme-examples.spec.js rename to packages/server/test/server-readme-snippets.spec.js index 5ad2033f..d8c1decc 100644 --- a/packages/server/test/readme-examples.spec.js +++ b/packages/server/test/server-readme-snippets.spec.js @@ -55,7 +55,7 @@ test('README service definition works', async () => { // Test that ed25519.parse works test('README uses correct ed25519.parse API', async () => { // This should work with the current API (not the old ed25519.Signer.parse) - const key = await ed25519.generate() + const key = await ed25519.generate({ extractable: true }) // Test that we can format and parse keys correctly const formatted = ed25519.format(key) diff --git a/packages/transport/README.md b/packages/transport/README.md index 1ce7242f..7ad72ec7 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -65,6 +65,8 @@ const replyMessage = await CAR.response.decode(response) console.log('Received:', replyMessage.receipts.size, 'receipts') ``` +> 📝 **Tested in**: [`transport-readme-snippets.spec.js`](./test/transport-readme-snippets.spec.js) + ### Server as Channel (for Testing) For testing, you can use a UCAN server directly as a channel without HTTP. See the [`@ucanto/server` README](../server/README.md) for examples of using a server as a channel. @@ -84,7 +86,8 @@ Create a file called `generate-keys.js`: import { ed25519 } from '@ucanto/principal' async function generateKeys() { - const keypair = await ed25519.generate() + // Use extractable keys if you need to serialize for env vars. + const keypair = await ed25519.generate({ extractable: true }) const privateKey = ed25519.format(keypair) @@ -94,6 +97,8 @@ async function generateKeys() { generateKeys().catch(console.error) ``` +> 📝 **Tested in**: [`transport-readme-snippets.spec.js`](./test/transport-readme-snippets.spec.js) + Then run it: ```bash @@ -142,6 +147,8 @@ const inbound = Codec.inbound({ }) ``` +> 📝 **Tested in**: [`transport-readme-snippets.spec.js`](./test/transport-readme-snippets.spec.js) + **What's happening:** Transport handles the low-level details of UCAN communication - encoding messages into CAR format, managing HTTP headers, content negotiation, and error handling. Most developers use `@ucanto/client` which handles this automatically. -For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file +For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/transport/test/transport-readme-snippets.spec.js b/packages/transport/test/transport-readme-snippets.spec.js new file mode 100644 index 00000000..373a2da5 --- /dev/null +++ b/packages/transport/test/transport-readme-snippets.spec.js @@ -0,0 +1,84 @@ +import { test, assert } from './test.js' +import * as HTTP from '../src/http.js' +import { CAR, Codec } from '../src/lib.js' +import { ed25519 } from '@ucanto/principal' +import { invoke, Message, DID, Receipt } from '@ucanto/core' + +test('README HTTP transport example works', async () => { + const serviceSigner = ed25519.parse( + 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' + ) + const service = DID.parse(serviceSigner.did()) + const issuer = ed25519.parse( + 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' + ) + + const invocation = invoke({ + issuer, + audience: service, + capability: { + can: 'store/add', + with: issuer.did(), + nb: { + link: 'bafybeigwflfnv7tjgpuy52ep45cbbgkkb2makd3bwhbj3ueabvt3eq43ca', + }, + }, + }) + + const message = await Message.build({ invocations: [invocation] }) + const request = await CAR.request.encode(message) + + const channel = HTTP.open({ + url: new URL('about:blank'), + fetch: async (url, init) => { + assert.equal(url, 'about:blank') + const decoded = await CAR.request.decode({ + headers: /** @type {Record} */ (init.headers), + body: /** @type {Uint8Array} */ (init.body), + }) + const [received] = decoded.invocations + + const receipt = await Receipt.issue({ + issuer: serviceSigner, + ran: received.link(), + result: { ok: { accepted: true } }, + }) + const response = await CAR.response.encode( + await Message.build({ receipts: [receipt] }) + ) + + return { + ok: true, + arrayBuffer: () => response.body.buffer, + headers: new Map([['content-type', CAR.contentType]]), + } + }, + }) + + const response = await channel.request(request) + const replyMessage = await CAR.response.decode(response) + assert.equal(replyMessage.receipts.size, 1) +}) + +test('README pluggable codec example works', async () => { + const outbound = Codec.outbound({ + encoders: { 'application/vnd.ipld.car': CAR.request }, + decoders: { 'application/vnd.ipld.car': CAR.response }, + }) + + const inbound = Codec.inbound({ + decoders: { 'application/vnd.ipld.car': CAR.request }, + encoders: { 'application/vnd.ipld.car': CAR.response }, + }) + + assert.ok(outbound) + assert.ok(inbound) +}) + +test('README AGENT_PRIVATE_KEY generation snippet works', async () => { + const keypair = await ed25519.generate({ extractable: true }) + const privateKey = ed25519.format(keypair) + const parsed = ed25519.parse(privateKey) + + assert.equal(parsed.did(), keypair.did()) +}) diff --git a/packages/validator/README.md b/packages/validator/README.md index ddba2f08..093d81c6 100644 --- a/packages/validator/README.md +++ b/packages/validator/README.md @@ -49,15 +49,10 @@ const storeAdd = capability({ const proof = await storeAdd.delegate({ issuer: alice, audience: bob, - capabilities: [ - { - with: alice.did(), - can: 'store/add', - nb: { - link: Link.parse('bafkqaaa') - } - } - ] + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa') + } }) // Bob tries to invoke the capability @@ -87,4 +82,6 @@ if (result.error) { } ``` -For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). \ No newline at end of file +> 📝 **Tested in**: [`validator-readme-snippets.spec.js`](./test/validator-readme-snippets.spec.js) + +For more details, see the [`ucanto` documentation](https://github.com/storacha/ucanto). diff --git a/packages/validator/test/validator-readme-snippets.spec.js b/packages/validator/test/validator-readme-snippets.spec.js new file mode 100644 index 00000000..ca75e7fe --- /dev/null +++ b/packages/validator/test/validator-readme-snippets.spec.js @@ -0,0 +1,51 @@ +import { test, assert } from './test.js' +import { access, DID, capability, fail, Link, Schema } from '../src/lib.js' +import { Verifier, ed25519 } from '@ucanto/principal' + +test('README validator example works', async () => { + const alice = await ed25519.generate() + const bob = await ed25519.generate() + + const storeAdd = capability({ + can: 'store/add', + with: DID, + nb: Schema.struct({ + link: Link, + size: Schema.integer().optional(), + }), + derives: (claim, proof) => { + if (claim.with !== proof.with) { + return fail('with field does not match') + } + return { ok: {} } + }, + }) + + const proof = await storeAdd.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + }, + }) + + const invocation = storeAdd.invoke({ + issuer: bob, + audience: alice, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + }, + proofs: [proof], + }) + + const result = await access(await invocation.delegate(), { + authority: alice, + capability: storeAdd, + principal: Verifier, + validateAuthorization: () => ({ ok: {} }), + }) + + assert.ok(!result.error, `Expected no error, got: ${result.error?.message}`) +})