diff --git a/packages/core/src/did-provider-signing-key.test.js b/packages/core/src/did-provider-signing-key.test.js new file mode 100644 index 00000000..f699e17c --- /dev/null +++ b/packages/core/src/did-provider-signing-key.test.js @@ -0,0 +1,66 @@ +const bs58 = require('base58-universal'); +const {Secp256r1Keypair} = require('@docknetwork/credential-sdk/keypairs'); +const {createWallet} = require('./wallet'); +const {createDIDProvider} = require('./did-provider'); +const { + createDataStore, +} = require('@docknetwork/wallet-sdk-data-store-typeorm/src'); + +describe('DID Provider - createSigningKey / signWithKeyId', () => { + let wallet; + let didProvider; + const controller = 'did:key:z6MkjjCpsoQrwnEmqHzLdxWowXk5gjbwor4urC1RPDmGeV8r'; + + beforeEach(async () => { + wallet = await createWallet({ + dataStore: await createDataStore({ + databasePath: ':memory:', + }), + }); + didProvider = createDIDProvider({wallet}); + }); + + it('expect to create a secp256r1 signing key and sign with it', async () => { + const keyDoc = await didProvider.createSigningKey({controller}); + + expect(keyDoc.controller).toEqual(controller); + expect(keyDoc.type).toEqual('EcdsaSecp256r1VerificationKey2019'); + expect(keyDoc.publicKeyBase58).toBeDefined(); + expect(keyDoc.privateKeyBase58).toBeDefined(); + + const data = new Uint8Array([1, 2, 3, 4, 5]); + const signature = await didProvider.signWithKeyId({ + keyId: keyDoc.id, + data, + }); + + expect(signature).toBeInstanceOf(Uint8Array); + + const publicKeyBytes = bs58.decode(keyDoc.publicKeyBase58); + expect(Secp256r1Keypair.verify(data, signature, publicKeyBytes)).toBe(true); + + // A signature over different data must not verify. + expect( + Secp256r1Keypair.verify( + new Uint8Array([9, 9, 9]), + signature, + publicKeyBytes, + ), + ).toBe(false); + }); + + it('expect to reject an invalid controller', async () => { + await expect( + didProvider.createSigningKey({controller: 'not-a-did'}), + ).rejects.toThrow(/valid DID/); + }); + + it('expect to reject signing with an unknown keyId', async () => { + await expect( + didProvider.signWithKeyId({ + keyId: 'does-not-exist', + data: new Uint8Array([1]), + }), + ).rejects.toThrow('No stored key document found'); + }); +}); diff --git a/packages/core/src/did-provider.ts b/packages/core/src/did-provider.ts index 1e214449..756cfaaf 100644 --- a/packages/core/src/did-provider.ts +++ b/packages/core/src/did-provider.ts @@ -210,6 +210,57 @@ export async function getDIDKeyPairs({wallet}) { } +// Minimal DID Core syntax check (did::). +// Not a full resolver-level validation, just enough to reject obviously +// malformed input before it's stored as a key document's controller. +const DID_SYNTAX = /^did:[a-z0-9]+:.+$/; + +/** + * Internal function to create and store a bare signing key document + * attached to an existing DID, without minting a new did:key document. + * Defaults to secp256r1 (P-256/ES256) since that's the first consumer + * (AP2 mandate `cnf`/holder-binding keys), but mirrors `createDIDKey`'s + * `type` param so other key types `generateKeyDoc` supports can reuse it. + * @private + */ +export async function createSigningKey({wallet, controller, name, type = 'secp256r1'}) { + assert(!!wallet, 'wallet is required'); + assert( + typeof controller === 'string' && DID_SYNTAX.test(controller), + `controller must be a valid DID (did::), got: ${controller}`, + ); + + const generatedKeyDoc = await didServiceRPC.generateKeyDoc({type, controller}); + const keyDoc = name ? {...generatedKeyDoc, name} : generatedKeyDoc; + + await wallet.addDocument(keyDoc); + return keyDoc; +} + +/** + * Internal function to sign arbitrary bytes with a previously-stored + * signing key document. + * @private + */ +export async function signWithKeyId({wallet, keyId, data}) { + assert(!!wallet, 'wallet is required'); + assert(!!keyId, 'keyId is required'); + + const keyDoc = await wallet.getDocumentById(keyId); + if (!keyDoc) { + throw new Error(`No stored key document found for keyId: ${keyId}`); + } + + // Cross the wasm JSON-RPC boundary (e.g. the RN WebView bridge) as a + // plain array in both directions: JSON-stringifying a Uint8Array turns + // it into an indexed object, not an array, and it loses its `.length`. + const signature = await didServiceRPC.signWithKeyDoc({ + privateKeyDoc: keyDoc, + data: Array.from(data), + }); + return Uint8Array.from(signature); +} + export async function ensureDID({wallet}) { assert(!!wallet, 'wallet is required'); const dids = await getAllDIDs({wallet}); @@ -375,6 +426,40 @@ export function createDIDProvider({wallet}): IDIDProvider { */ async getDefaultDID() { return getDefaultDID({wallet}); + }, + /** + * Creates and stores a bare signing key document attached to an + * existing DID, without minting a new did:key document. + * @memberof IDIDProvider + * @param {Object} params - Creation parameters + * @param {string} params.controller - The DID this key is attached to + * @param {string} [params.name] - Optional label for the stored document + * @param {string} [params.type] - Key type to generate (default 'secp256r1') + * @returns {Promise} The stored key document + * @example + * const keyDoc = await didProvider.createSigningKey({ + * controller: 'did:key:z6Mk...', + * }); + */ + async createSigningKey(params) { + return createSigningKey({wallet, ...params}); + }, + /** + * Signs arbitrary bytes with a previously-stored signing key document + * and returns the raw signature bytes. + * @memberof IDIDProvider + * @param {Object} params - Signing parameters + * @param {string} params.keyId - The id of the stored key document + * @param {Uint8Array} params.data - The bytes to sign + * @returns {Promise} The raw signature bytes + * @example + * const signature = await didProvider.signWithKeyId({ + * keyId: keyDoc.id, + * data: signingInputBytes, + * }); + */ + async signWithKeyId(params) { + return signWithKeyId({wallet, ...params}); } }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4ca7d9d6..60056ff0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -382,6 +382,34 @@ export interface IDIDProvider { * @returns {Promise} The default DID identifier or undefined if no DIDs exist */ getDefaultDID: () => Promise; + + /** + * Creates and stores a P-256 (secp256r1) signing key attached to an + * existing DID as its controller. Unlike `createDIDKey`, this does not + * mint a new did:key document — it's a bare keypair document for + * callers that need a raw ES256-capable signing key (e.g. AP2 mandate + * `cnf`/holder-binding keys) associated with a DID they already control. + * @param {Object} params - Creation parameters + * @param {string} params.controller - The DID this key document is attached to + * @param {string} [params.name] - Optional label for the stored document + * @param {string} [params.type] - Key type to generate (default 'secp256r1') + * @returns {Promise} The stored key document (includes `id`, `publicKeyBase58`, etc.) + * @throws {Error} If controller is not a valid DID + */ + createSigningKey: (params: {controller: string; name?: string; type?: string}) => Promise; + + /** + * Signs arbitrary bytes with a previously-stored signing key (e.g. one + * created via `createSigningKey`) and returns the raw signature bytes — + * no JWT/VC framing. Works with any stored key document, not just + * did:key-method DIDs. + * @param {Object} params - Signing parameters + * @param {string} params.keyId - The id of the stored key document to sign with + * @param {Uint8Array} params.data - The bytes to sign + * @returns {Promise} The raw signature bytes + * @throws {Error} If no stored document matches keyId + */ + signWithKeyId: (params: {keyId: string; data: Uint8Array}) => Promise; } /** diff --git a/packages/wasm/src/services/credential/utils.js b/packages/wasm/src/services/credential/utils.js index c36c5bff..af10ee83 100644 --- a/packages/wasm/src/services/credential/utils.js +++ b/packages/wasm/src/services/credential/utils.js @@ -10,7 +10,11 @@ import {X25519KeyAgreementKey2019} from '@digitalbazaar/x25519-key-agreement-key import {Ed25519VerificationKey2018} from '@digitalbazaar/ed25519-verification-key-2018'; import {Ed25519VerificationKey2020} from '@digitalbazaar/ed25519-verification-key-2020'; -import {Ed25519Keypair} from '@docknetwork/credential-sdk/keypairs'; +import { + Ed25519Keypair, + Secp256r1Keypair, +} from '@docknetwork/credential-sdk/keypairs'; +import {EcdsaSecp256r1VerKeyName} from '@docknetwork/credential-sdk/vc/crypto'; export async function keyDocToKeypair(keyDoc) { if (keyDoc.keypair) { @@ -53,6 +57,8 @@ export async function keyDocToKeypair(keyDoc) { key.keyPair.publicKey = publicKeyBytes; key.keyPair.secretKey = privateKeyBytes; return key; + } else if (type === EcdsaSecp256r1VerKeyName) { + return new Secp256r1Keypair(privateKeyBytes, 'private'); } else { throw new Error(`Unsupported key type: ${type}`); } diff --git a/packages/wasm/src/services/dids/keypair-utils.js b/packages/wasm/src/services/dids/keypair-utils.js index 46280f8f..423aa203 100644 --- a/packages/wasm/src/services/dids/keypair-utils.js +++ b/packages/wasm/src/services/dids/keypair-utils.js @@ -4,6 +4,7 @@ */ import {Ed25519Keypair} from '@docknetwork/credential-sdk/keypairs'; import {hexToU8a, u8aToHex, u8aToU8a} from '@docknetwork/credential-sdk/utils'; +import {EcdsaSecp256r1VerKeyName} from '@docknetwork/credential-sdk/vc/crypto'; // import {encodeBase58} from './dock-shared'; import * as bs58 from 'base58-universal'; @@ -23,6 +24,10 @@ export function getKeyPairType(key) { export const MULTIBASE_BASE58BTC_HEADER = 'z'; export const MULTICODEC_ED25519_PUB_HEADER = new Uint8Array([0xed, 0x01]); export const MULTICODEC_ED25519_PRIV_HEADER = new Uint8Array([0x80, 0x26]); +// Multicodec table: p256-pub = 0x1200, p256-priv = 0x1306 (varint-encoded). +// https://github.com/multiformats/multicodec/blob/master/table.csv +export const MULTICODEC_P256_PUB_HEADER = new Uint8Array([0x80, 0x24]); +export const MULTICODEC_P256_PRIV_HEADER = new Uint8Array([0x86, 0x26]); export function encodeMbKey(header, key) { const mbKey = new Uint8Array(header.length + key.length); @@ -34,6 +39,8 @@ export function encodeMbKey(header, key) { function getKeyFingerprint(keyType, publicKey) { if (keyType.startsWith('Ed25519')) { return encodeMbKey(MULTICODEC_ED25519_PUB_HEADER, publicKey); + } else if (keyType === EcdsaSecp256r1VerKeyName) { + return encodeMbKey(MULTICODEC_P256_PUB_HEADER, publicKey); } else { throw new Error(`Cannot detect key type for fingerprint: ${keyType}`); } @@ -101,6 +108,33 @@ export function keypairToKeydoc(key, controller, id = undefined) { if (key.seed) { keyDoc.seed = u8aToHex(key.seed); } + } else if (keyType === EcdsaSecp256r1VerKeyName) { + // Not a did:key type: this key is meant to be attached to an existing + // DID (e.g. as an AP2 mandate signing key), not to mint a new + // did:key identifier, so a controller must be supplied by the caller. + if (!controller) { + throw new Error( + 'A controller DID is required to store a secp256r1 key document', + ); + } + + const publicKey = key.publicKey().value.bytes; + const pk = key.privateKey(); + + const publicKeyBase58 = bs58.encode(publicKey); + const privateKeyBase58 = bs58.encode(pk); + + const fingerprint = getKeyFingerprint(keyType, publicKey); + const keyId = id || key.id || `${controller}#${fingerprint}`; + keyDoc = { + controller, + type: keyType, + id: keyId, + publicKeyMultibase: encodeMbKey(MULTICODEC_P256_PUB_HEADER, publicKey), + privateKeyMultibase: encodeMbKey(MULTICODEC_P256_PRIV_HEADER, pk), + privateKeyBase58, + publicKeyBase58, + }; } else { throw new Error(`Unknown keypairToKeydoc type: ${keyType}`); } diff --git a/packages/wasm/src/services/dids/service-rpc.ts b/packages/wasm/src/services/dids/service-rpc.ts index 1707c82b..9b2ada20 100644 --- a/packages/wasm/src/services/dids/service-rpc.ts +++ b/packages/wasm/src/services/dids/service-rpc.ts @@ -24,4 +24,7 @@ export class DIDServiceRPC extends RpcService { createSignedJWT(params) { return this.call('createSignedJWT', params); } + signWithKeyDoc(params) { + return this.call('signWithKeyDoc', params); + } } diff --git a/packages/wasm/src/services/dids/service.ts b/packages/wasm/src/services/dids/service.ts index dc2b41d7..1eb236df 100644 --- a/packages/wasm/src/services/dids/service.ts +++ b/packages/wasm/src/services/dids/service.ts @@ -16,7 +16,7 @@ import { VerificationRelationship, DidMethodKey, } from '@docknetwork/credential-sdk/types'; -import {Ed25519Keypair} from '@docknetwork/credential-sdk/keypairs'; +import {Ed25519Keypair, Secp256r1Keypair} from '@docknetwork/credential-sdk/keypairs'; import {Logger} from '../../core/logger'; import base64url from 'base64url'; @@ -67,6 +67,7 @@ class DIDService { DIDService.prototype.generateKeyDoc, DIDService.prototype.deriveKeyDoc, DIDService.prototype.createSignedJWT, + DIDService.prototype.signWithKeyDoc, ]; keypairToDIDKeyDocument(params: KeypairToDIDKeyDocumentParams) { validation.keypairToDIDKeyDocument(params); @@ -83,7 +84,8 @@ class DIDService { async generateKeyDoc(params) { validation.generateKeyDoc(params); const {derivePath = '', type = 'ed25519'} = params; - const keyPair = Ed25519Keypair.random() + const keyPair = + type === 'secp256r1' ? Secp256r1Keypair.random() : Ed25519Keypair.random(); return keypairToKeydoc(keyPair, params.controller); } @@ -113,6 +115,24 @@ class DIDService { const signature = await sign({data: signPayload}); return `${headerAndPayloadBase64URL}.${base64url.encode(signature)}`; } + + /** + * Signs arbitrary bytes with the keypair from a stored key document + * (any DID method — not specific to the did:key method) and returns the + * raw signature bytes, with no header/payload framing. Distinct from + * `createSignedJWT`, which builds a full compact JWT — this is for + * callers (e.g. AP2 mandate signing) that assemble their own signing + * input and just need a raw signature back. + */ + async signWithKeyDoc({data, privateKeyDoc}) { + const keypair = privateKeyDoc.keypair || (await keyDocToKeypair(privateKeyDoc)); + const message = data instanceof Uint8Array ? data : Uint8Array.from(data); + const signature = keypair.sign(message); + // Return a plain array: this crosses a JSON-RPC boundary (e.g. the RN + // WebView bridge), which JSON-stringifies params/results and would + // otherwise turn a Uint8Array into an indexed object, losing its shape. + return Array.from(signature.bytes ?? signature); + } } export const didService = new DIDService();