diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 63044a5..54f7cf4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -59,8 +59,8 @@ export interface Didable { did: () => string } -export interface ExportableKey { - export: (format?: Encodings) => Promise +export interface ExportableKey { + export: () => Promise } export interface Keypair { @@ -68,7 +68,7 @@ export interface Keypair { sign: (msg: Uint8Array) => Promise } -export interface DidableKey extends Didable, Keypair {} +export interface DidableKey extends Didable, Keypair { } // MISC @@ -80,21 +80,21 @@ export type Encodings = SupportedEncodings export interface IndexByAudience { - [ audienceDID: string ]: Array<{ + [audienceDID: string]: Array<{ processedUcan: Ucan capabilities: DelegationChain[] }> } export interface StoreI { - add(ucan: Ucan): Promise - getByAudience(audience: string): Ucan[] - findByAudience(audience: string, predicate: (ucan: Ucan) => boolean): Ucan | null + add(ucan: Ucan): Promise + getByAudience(audience: string): Ucan[] + findByAudience(audience: string, predicate: (ucan: Ucan) => boolean): Ucan | null findWithCapability( audience: string, requiredCapability: Capability, requiredIssuer: string, - ): Iterable + ): Iterable } // BUILDER diff --git a/packages/default-plugins/src/ed25519/keypair.ts b/packages/default-plugins/src/ed25519/keypair.ts index d217546..ae8810d 100644 --- a/packages/default-plugins/src/ed25519/keypair.ts +++ b/packages/default-plugins/src/ed25519/keypair.ts @@ -3,9 +3,10 @@ import * as ed25519 from "@stablelib/ed25519" import * as crypto from "./crypto.js" import { DidableKey, Encodings, ExportableKey } from "@ucans/core" +import { PrivateKeyJwk } from "../types.js" -export class EdKeypair implements DidableKey, ExportableKey { +export class EdKeypair implements DidableKey, ExportableKey { public jwtAlg = "EdDSA" @@ -45,13 +46,44 @@ export class EdKeypair implements DidableKey, ExportableKey { return ed25519.sign(this.secretKey, msg) } - async export(format: Encodings = "base64pad"): Promise { + async export(): Promise { if (!this.exportable) { throw new Error("Key is not exportable") } - return uint8arrays.toString(this.secretKey, format) + + /* + * EdDSA is relatively new and not supported everywhere. There's no good documentation + * within the JWK spec or parameter export to be able to reconstruct the key via parameters + * Example, there's no good documentation on parameterizing like other curves: (x, y, n, e) + * + * In an effort to remain compatible with other tooling in the space, the following article + * describes a way of encoding JWK that is at least consistent with other tooling. As our current + * libraries are only able to reconstruct a key via importing a secret key, encoding the secret + * as the `d` parameter seems to make sense and have some compatibility with other tools. + * + * [Link](https://gist.github.com/kousu/f3174af57e1fc42a0a88586b5a5ffdc9) + * + * While `kty` and `crv` are not absolutely required for this to work within the library, + * including them is an attempt to be closer to the [JWK Spec](https://datatracker.ietf.org/doc/html/rfc7517) + * since we are hand rolling this export. + */ + const jwk: PrivateKeyJwk = { + kty: "EC", + crv: "Ed25519", + d: uint8arrays.toString(this.secretKey, "base64pad"), + } + return jwk } + static async import(jwk: PrivateKeyJwk, params?: { exportable: boolean }): Promise { + const { exportable = false } = params || {} + + if (jwk.kty !== "EC" || jwk.crv !== "Ed25519") { + throw new Error("Cannot import key of type: ${jwk.kty} curve: ${jwk.crv} into ED25519 key") + } + + return EdKeypair.fromSecretKey(jwk.d, { exportable }) + } } diff --git a/packages/default-plugins/src/p256/crypto.ts b/packages/default-plugins/src/p256/crypto.ts index ef8dcff..81e035e 100644 --- a/packages/default-plugins/src/p256/crypto.ts +++ b/packages/default-plugins/src/p256/crypto.ts @@ -9,14 +9,16 @@ export const ALG = "ECDSA" export const DEFAULT_CURVE = "P-256" export const DEFAULT_HASH_ALG = "SHA-256" -export const generateKeypair = async (): Promise => { +export const generateKeypair = async ( + exportable = false +): Promise => { return await webcrypto.subtle.generateKey( { name: ALG, namedCurve: DEFAULT_CURVE, }, - false, - [ "sign", "verify" ] + exportable, + ["sign", "verify"] ) } @@ -32,10 +34,10 @@ export const importKeypairJwk = async ( namedCurve: DEFAULT_CURVE, }, exportable, - ["sign" ] + ["sign"] ) - const { kty, crv, x, y} = privKeyJwk - const pubKeyJwk = { kty, crv, x, y} + const { kty, crv, x, y } = privKeyJwk + const pubKeyJwk = { kty, crv, x, y } const publicKey = await webcrypto.subtle.importKey( "jwk", pubKeyJwk, @@ -44,11 +46,17 @@ export const importKeypairJwk = async ( namedCurve: DEFAULT_CURVE, }, true, - [ "verify" ] + ["verify"] ) return { privateKey, publicKey } } +export const exportPrivateKeyJwk = async ( + keyPair: AvailableCryptoKeyPair +): Promise => { + return await webcrypto.subtle.exportKey("jwk", keyPair.privateKey) as PrivateKeyJwk +} + export const exportKey = async (key: CryptoKey): Promise => { const buf = await webcrypto.subtle.exportKey("raw", key) return new Uint8Array(buf) @@ -62,7 +70,7 @@ export const importKey = async ( key, { name: ALG, namedCurve: DEFAULT_CURVE }, true, - [ "verify" ] + ["verify"] ) } diff --git a/packages/default-plugins/src/p256/keypair.ts b/packages/default-plugins/src/p256/keypair.ts index 76b8e2f..f612dd7 100644 --- a/packages/default-plugins/src/p256/keypair.ts +++ b/packages/default-plugins/src/p256/keypair.ts @@ -1,6 +1,4 @@ -import { webcrypto } from "one-webcrypto" -import * as uint8arrays from "uint8arrays" -import { DidableKey, Encodings, ExportableKey } from "@ucans/core" +import { DidableKey, ExportableKey } from "@ucans/core" import * as crypto from "./crypto.js" import { @@ -10,7 +8,7 @@ import { } from "../types.js" -export class EcdsaKeypair implements DidableKey, ExportableKey { +export class EcdsaKeypair implements DidableKey, ExportableKey { public jwtAlg = "ES256" @@ -32,7 +30,7 @@ export class EcdsaKeypair implements DidableKey, ExportableKey { exportable?: boolean }): Promise { const { exportable = false } = params || {} - const keypair = await crypto.generateKeypair() + const keypair = await crypto.generateKeypair(exportable) if (!isAvailableCryptoKeyPair(keypair)) { throw new Error(`Couldn't generate valid keypair`) @@ -47,12 +45,12 @@ export class EcdsaKeypair implements DidableKey, ExportableKey { params?: { exportable?: boolean }): Promise { - const { exportable = false } = params || {} - const keypair = await crypto.importKeypairJwk(jwk, exportable) + const { exportable = false } = params || {} + const keypair = await crypto.importKeypairJwk(jwk, exportable) - if (!isAvailableCryptoKeyPair(keypair)) { - throw new Error(`Couldn't generate valid keypair`) - } + if (!isAvailableCryptoKeyPair(keypair)) { + throw new Error(`Couldn't generate valid keypair`) + } const publicKey = await crypto.exportKey(keypair.publicKey) return new EcdsaKeypair(keypair, publicKey, exportable) @@ -66,15 +64,22 @@ export class EcdsaKeypair implements DidableKey, ExportableKey { return await crypto.sign(msg, this.keypair.privateKey) } - async export(format: Encodings = "base64pad"): Promise { + async export(): Promise { if (!this.exportable) { throw new Error("Key is not exportable") } - const arrayBuffer = await webcrypto.subtle.exportKey( - "pkcs8", - this.keypair.privateKey - ) - return uint8arrays.toString(new Uint8Array(arrayBuffer), format) + return await crypto.exportPrivateKeyJwk(this.keypair) + } + + /** + * Convenience function on the Keypair class to allow for keys to be exported / persisted. + * This is most useful for situations where you want to have consistent keys between restarts. + * A Developer can export a key, save it in a vault, and rehydrate it for use in a later run. + * @param jwk + * @returns + */ + static async import(jwk: PrivateKeyJwk): Promise { + return EcdsaKeypair.importFromJwk(jwk, { exportable: true }) } } diff --git a/packages/default-plugins/src/rsa/crypto.ts b/packages/default-plugins/src/rsa/crypto.ts index 339d38b..da120a2 100644 --- a/packages/default-plugins/src/rsa/crypto.ts +++ b/packages/default-plugins/src/rsa/crypto.ts @@ -2,6 +2,7 @@ import { webcrypto } from "one-webcrypto" import * as uint8arrays from "uint8arrays" import { RSA_DID_PREFIX, RSA_DID_PREFIX_OLD } from "../prefixes.js" import { didFromKeyBytes, keyBytesFromDid } from "../util.js" +import { AvailableCryptoKeyPair, PrivateKeyJwk } from "../types.js" export const RSA_ALG = "RSASSA-PKCS1-v1_5" export const DEFAULT_KEY_SIZE = 2048 @@ -9,16 +10,16 @@ export const DEFAULT_HASH_ALG = "SHA-256" export const SALT_LEGNTH = 128 -export const generateKeypair = async (size: number = DEFAULT_KEY_SIZE): Promise => { +export const generateKeypair = async (size: number = DEFAULT_KEY_SIZE, exportable: boolean = false): Promise => { return await webcrypto.subtle.generateKey( { name: RSA_ALG, modulusLength: size, - publicExponent: new Uint8Array([ 0x01, 0x00, 0x01 ]), + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), hash: { name: DEFAULT_HASH_ALG } }, - false, - [ "sign", "verify" ] + exportable, + ["sign", "verify"] ) } @@ -27,14 +28,47 @@ export const exportKey = async (key: CryptoKey): Promise => { return new Uint8Array(buf) } +export const exportPrivateKeyJwk = async (keyPair: AvailableCryptoKeyPair): Promise => { + return await webcrypto.subtle.exportKey("jwk", keyPair.privateKey) +} + export const importKey = async (key: Uint8Array): Promise => { return await webcrypto.subtle.importKey( "spki", key, { name: RSA_ALG, hash: { name: DEFAULT_HASH_ALG } }, true, - [ "verify" ] + ["verify"] + ) +} + +export const importKeypairJwk = async ( + privKeyJwk: PrivateKeyJwk, + exportable = false +): Promise => { + const privateKey = await webcrypto.subtle.importKey( + "jwk", + privKeyJwk, + { + name: RSA_ALG, + hash: { name: DEFAULT_HASH_ALG }, + }, + exportable, + ["sign"] + ) + const { kty, n, e } = privKeyJwk + const pubKeyJwk = { kty, n, e } + const publicKey = await webcrypto.subtle.importKey( + "jwk", + pubKeyJwk, + { + name: RSA_ALG, + hash: { name: DEFAULT_HASH_ALG }, + }, + true, + ["verify"] ) + return { privateKey, publicKey } } export const sign = async (msg: Uint8Array, privateKey: CryptoKey): Promise => { @@ -100,16 +134,16 @@ export const publicKeyToOldDid = (pubkey: Uint8Array): string => { * * See https://github.com/ucan-wg/ts-ucan/issues/30 */ -const SPKI_PARAMS_ENCODED = new Uint8Array([ 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0 ]) -const ASN_SEQUENCE_TAG = new Uint8Array([ 0x30 ]) -const ASN_BITSTRING_TAG = new Uint8Array([ 0x03 ]) +const SPKI_PARAMS_ENCODED = new Uint8Array([48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0]) +const ASN_SEQUENCE_TAG = new Uint8Array([0x30]) +const ASN_BITSTRING_TAG = new Uint8Array([0x03]) export const convertRSAPublicKeyToSubjectPublicKeyInfo = (rsaPublicKey: Uint8Array): Uint8Array => { // More info on bitstring encoding: https://docs.microsoft.com/en-us/windows/win32/seccertenroll/about-bit-string const bitStringEncoded = uint8arrays.concat([ ASN_BITSTRING_TAG, asn1DERLengthEncode(rsaPublicKey.length + 1), - new Uint8Array([ 0x00 ]), // amount of unused bits at the end of our bitstring (counts into length?!) + new Uint8Array([0x00]), // amount of unused bits at the end of our bitstring (counts into length?!) rsaPublicKey ]) return uint8arrays.concat([ @@ -129,7 +163,7 @@ export const convertSubjectPublicKeyInfoToRSAPublicKey = (subjectPublicKeyInfo: // we expect the bitstring next const bitstringParams = asn1Into(subjectPublicKeyInfo, ASN_BITSTRING_TAG, position) const bitstring = subjectPublicKeyInfo.subarray(bitstringParams.position, bitstringParams.position + bitstringParams.length) - const unusedBitPadding = bitstring[ 0 ] + const unusedBitPadding = bitstring[0] if (unusedBitPadding !== 0) { throw new Error(`Can't convert SPKI to PKCS: Expected bitstring length to be multiple of 8, but got ${unusedBitPadding} unused bits in last byte.`) } @@ -145,7 +179,7 @@ export function asn1DERLengthEncode(length: number): Uint8Array { } if (length <= 127) { - return new Uint8Array([ length ]) + return new Uint8Array([length]) } const octets: number[] = [] @@ -154,15 +188,15 @@ export function asn1DERLengthEncode(length: number): Uint8Array { length = length >>> 8 } octets.reverse() - return new Uint8Array([ 0x80 | (octets.length & 0xFF), ...octets ]) + return new Uint8Array([0x80 | (octets.length & 0xFF), ...octets]) } function asn1DERLengthDecodeWithConsumed(bytes: Uint8Array): { number: number; consumed: number } { - if ((bytes[ 0 ] & 0x80) === 0) { - return { number: bytes[ 0 ], consumed: 1 } + if ((bytes[0] & 0x80) === 0) { + return { number: bytes[0], consumed: 1 } } - const numberBytes = bytes[ 0 ] & 0x7F + const numberBytes = bytes[0] & 0x7F if (bytes.length < numberBytes + 1) { throw new Error(`ASN parsing error: Too few bytes. Expected encoded length's length to be at least ${numberBytes}`) } @@ -170,7 +204,7 @@ function asn1DERLengthDecodeWithConsumed(bytes: Uint8Array): { number: number; c let length = 0 for (let i = 0; i < numberBytes; i++) { length = length << 8 - length = length | bytes[ i + 1 ] + length = length | bytes[i + 1] } return { number: length, consumed: numberBytes + 1 } } diff --git a/packages/default-plugins/src/rsa/keypair.ts b/packages/default-plugins/src/rsa/keypair.ts index 0c5dff1..a75396a 100644 --- a/packages/default-plugins/src/rsa/keypair.ts +++ b/packages/default-plugins/src/rsa/keypair.ts @@ -1,12 +1,9 @@ -import { webcrypto } from "one-webcrypto" -import * as uint8arrays from "uint8arrays" - import * as crypto from "./crypto.js" -import { AvailableCryptoKeyPair, isAvailableCryptoKeyPair } from "../types.js" -import { DidableKey, Encodings, ExportableKey } from "@ucans/core" +import { AvailableCryptoKeyPair, PrivateKeyJwk, isAvailableCryptoKeyPair } from "../types.js" +import { DidableKey, ExportableKey } from "@ucans/core" -export class RsaKeypair implements DidableKey, ExportableKey { +export class RsaKeypair implements DidableKey, ExportableKey{ public jwtAlg = "RS256" @@ -25,7 +22,7 @@ export class RsaKeypair implements DidableKey, ExportableKey { exportable?: boolean }): Promise { const { size = 2048, exportable = false } = params || {} - const keypair = await crypto.generateKeypair(size) + const keypair = await crypto.generateKeypair(size, exportable) if (!isAvailableCryptoKeyPair(keypair)) { throw new Error(`Couldn't generate valid keypair`) } @@ -41,14 +38,28 @@ export class RsaKeypair implements DidableKey, ExportableKey { return await crypto.sign(msg, this.keypair.privateKey) } - async export(format: Encodings = "base64pad"): Promise { + async export(): Promise { if (!this.exportable) { throw new Error("Key is not exportable") } - const arrayBuffer = await webcrypto.subtle.exportKey("pkcs8", this.keypair.privateKey) - return uint8arrays.toString(new Uint8Array(arrayBuffer), format) + return await crypto.exportPrivateKeyJwk(this.keypair) as PrivateKeyJwk } + static async importFromJwk(jwk: PrivateKeyJwk, params: { exportable: true }): Promise { + const { exportable = false } = params || {} + const keypair = await crypto.importKeypairJwk(jwk, exportable) + + if (!isAvailableCryptoKeyPair(keypair)) { + throw new Error(`Couldn't generate valid keypair`) + } + + const publicKey = await crypto.exportKey(keypair.publicKey) + return new RsaKeypair(keypair, publicKey, exportable) + } + + static async import(jwk: PrivateKeyJwk): Promise { + return RsaKeypair.importFromJwk(jwk, { exportable: true }) + } } export default RsaKeypair diff --git a/packages/default-plugins/src/types.ts b/packages/default-plugins/src/types.ts index 23489be..aeca589 100644 --- a/packages/default-plugins/src/types.ts +++ b/packages/default-plugins/src/types.ts @@ -6,8 +6,15 @@ export interface AvailableCryptoKeyPair { export type PublicKeyJwk = { kty: string crv: string - x: string - y: string + + // For P256 curves + x?: string + y?: string + + // For RSA curves + n?: string + e?: string + } export type PrivateKeyJwk = PublicKeyJwk & { d: string } diff --git a/packages/default-plugins/tests/ecdsa.test.ts b/packages/default-plugins/tests/ecdsa.test.ts index 214c4c3..d45b9aa 100644 --- a/packages/default-plugins/tests/ecdsa.test.ts +++ b/packages/default-plugins/tests/ecdsa.test.ts @@ -1,7 +1,7 @@ +import * as uint8arrays from "uint8arrays" import { p256Plugin } from "../src/p256/plugin.js" import EcdsaKeypair from "../src/p256/keypair.js" - describe("ecdsa", () => { let keypair: EcdsaKeypair @@ -54,10 +54,51 @@ const testVectors = [ describe("ecdsa did:key", () => { it("derives the correct DID from the JWK", async () => { - for(const vector of testVectors) { + for (const vector of testVectors) { const keypair = await EcdsaKeypair.importFromJwk(vector.jwk) const did = keypair.did() expect(did).toEqual(vector.id) } }) -}) \ No newline at end of file +}) + +describe("import and exporting a key", () => { + let exportableKeypair: EcdsaKeypair + let nonExportableKeypair: EcdsaKeypair + + beforeAll(async () => { + exportableKeypair = await EcdsaKeypair.create({ exportable: true }) + nonExportableKeypair = await EcdsaKeypair.create({ exportable: false }) + }) + + it("can export a key using jwk", async () => { + const exported = await exportableKeypair.export() + expect(exported.kty).toBe("EC") + expect(exported.crv).toBe("P-256") + }) + + it("won't export a non exportable keypar", async () => { + await expect(nonExportableKeypair.export()) + .rejects + .toThrow("Key is not exportable") + }) + + it("Can export a key and re-import from it", async () => { + const exported = await exportableKeypair.export() + + const jwk = exported + const newKey = await EcdsaKeypair.import(jwk) + + const msg = uint8arrays.fromString("test message", "utf-8") + + // Expect the public keys to match + expect(exportableKeypair.did()).toEqual(newKey.did()) + + // Verify old and new keys are compatible + let signedMessage = await exportableKeypair.sign(msg) + expect(await p256Plugin.verifySignature(newKey.did(), msg, signedMessage)).toBe(true) + + signedMessage = await newKey.sign(msg) + expect(await p256Plugin.verifySignature(exportableKeypair.did(), msg, signedMessage)).toBe(true) + }) +}) diff --git a/packages/default-plugins/tests/ed25519.test.ts b/packages/default-plugins/tests/ed25519.test.ts index 72ee25c..45859df 100644 --- a/packages/default-plugins/tests/ed25519.test.ts +++ b/packages/default-plugins/tests/ed25519.test.ts @@ -1,5 +1,6 @@ +import * as uint8arrays from "uint8arrays" import { ed25519Plugin } from "../src/ed25519/plugin.js" -import EdwardsKey from "../src/ed25519/keypair.js" +import EdwardsKey, { EdKeypair } from "../src/ed25519/keypair.js" describe("ed25519", () => { @@ -25,3 +26,39 @@ describe("ed25519", () => { }) }) + +describe("Import / Export", () => { + let exportableKey: EdKeypair + let nonExportableKey: EdKeypair + + beforeAll(async () => { + exportableKey = await EdKeypair.create({ exportable: true }) + nonExportableKey = await EdKeypair.create({ exportable: false }) + }) + + it("Will export a key that is exportable", async () => { + const exported = exportableKey.export() + expect(exported).not.toBe(null) + }) + + it("Will not export a key that is not exportable", async () => { + await expect(nonExportableKey.export()) + .rejects + .toThrow("Key is not exportable") + }) + + it("Will import an exported key", async () => { + const exported = await exportableKey.export() + const newKey = await EdKeypair.import(exported) + + expect(newKey.did()).toEqual(exportableKey.did()) + + // Sign and verify + const msg = uint8arrays.fromString("test signing", "utf-8") + let signed = await exportableKey.sign(msg) + expect(await ed25519Plugin.verifySignature(await newKey.did(), msg, signed)).toBe(true) + + signed = await newKey.sign(msg) + expect(await ed25519Plugin.verifySignature(await exportableKey.did(), msg, signed)).toBe(true) + }) +}) \ No newline at end of file diff --git a/packages/default-plugins/tests/rsa.test.ts b/packages/default-plugins/tests/rsa.test.ts index e18f279..5b2a00d 100644 --- a/packages/default-plugins/tests/rsa.test.ts +++ b/packages/default-plugins/tests/rsa.test.ts @@ -2,7 +2,7 @@ import * as fc from "fast-check" import * as uint8arrays from "uint8arrays" import { rsaPlugin } from "../src/rsa/plugin.js" import * as rsaCrypto from "../src/rsa/crypto.js" -import RSAKeypair from "../src/rsa/keypair.js" +import RSAKeypair, { RsaKeypair } from "../src/rsa/keypair.js" describe("rsa", () => { @@ -97,4 +97,41 @@ describe("ASN", () => { }) }) + + describe("Import / Export", () => { + let exportableKey: RSAKeypair + let nonExportableKey: RSAKeypair + + beforeAll(async () => { + exportableKey = await RSAKeypair.create({ exportable: true }) + nonExportableKey = await RSAKeypair.create({ exportable: false }) + + }) + + it("can export a key if marked exportable", async () => { + const exported = await exportableKey.export() + expect(exported).not.toBeNull() + }) + + it("cannont export a key if not marked exportable", async () => { + await expect(nonExportableKey.export()) + .rejects + .toThrow("Key is not exportable") + }) + + it("can import an exported key", async () => { + const exported = await exportableKey.export() + const newKey = await RsaKeypair.import(exported) + + expect(newKey.did()).toEqual(exportableKey.did()) + + // Cross sign and verify + const msg = uint8arrays.fromString("test signing", "utf-8") + let signed = await exportableKey.sign(msg) + expect(await rsaPlugin.verifySignature(newKey.did(), msg, signed)).toBe(true) + + signed = await newKey.sign(msg) + expect(await rsaPlugin.verifySignature(exportableKey.did(), msg, signed)).toBe(true) + }) + }) })