Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/core/src/did-provider-signing-key.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
85 changes: 85 additions & 0 deletions packages/core/src/did-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,57 @@ export async function getDIDKeyPairs({wallet}) {
}


// Minimal DID Core syntax check (did:<method-name>:<method-specific-id>).
// 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:<method>:<id>), 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});
Expand Down Expand Up @@ -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<any>} 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<Uint8Array>} The raw signature bytes
* @example
* const signature = await didProvider.signWithKeyId({
* keyId: keyDoc.id,
* data: signingInputBytes,
* });
*/
async signWithKeyId(params) {
return signWithKeyId({wallet, ...params});
}
};

Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,34 @@ export interface IDIDProvider {
* @returns {Promise<string|undefined>} The default DID identifier or undefined if no DIDs exist
*/
getDefaultDID: () => Promise<string>;

/**
* 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<any>} 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<any>;

/**
* 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<Uint8Array>} The raw signature bytes
* @throws {Error} If no stored document matches keyId
*/
signWithKeyId: (params: {keyId: string; data: Uint8Array}) => Promise<Uint8Array>;
}

/**
Expand Down
8 changes: 7 additions & 1 deletion packages/wasm/src/services/credential/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`);
}
Expand Down
34 changes: 34 additions & 0 deletions packages/wasm/src/services/dids/keypair-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand All @@ -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}`);
}
Expand Down Expand Up @@ -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}`);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/wasm/src/services/dids/service-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ export class DIDServiceRPC extends RpcService {
createSignedJWT(params) {
return this.call('createSignedJWT', params);
}
signWithKeyDoc(params) {
return this.call('signWithKeyDoc', params);
}
}
24 changes: 22 additions & 2 deletions packages/wasm/src/services/dids/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +67,7 @@ class DIDService {
DIDService.prototype.generateKeyDoc,
DIDService.prototype.deriveKeyDoc,
DIDService.prototype.createSignedJWT,
DIDService.prototype.signWithKeyDoc,
];
keypairToDIDKeyDocument(params: KeypairToDIDKeyDocumentParams) {
validation.keypairToDIDKeyDocument(params);
Expand All @@ -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);
}

Expand Down Expand Up @@ -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();
Loading