diff --git a/.github/workflows/principal.yml b/.github/workflows/principal.yml index f22b87b3..26051f96 100644 --- a/.github/workflows/principal.yml +++ b/.github/workflows/principal.yml @@ -51,7 +51,6 @@ jobs: strategy: matrix: node-version: - - 14 - 16 os: - ubuntu-latest diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 0fd7aea4..bd5cabd2 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -15,7 +15,7 @@ import { Signature, Principal, Verifier, - Signer, + Signer as UCANSigner, } from '@ipld/dag-ucan' import * as UCAN from '@ipld/dag-ucan' import { @@ -39,8 +39,6 @@ export * from './transport.js' export type { Transport, Principal, - Verifier, - Signer, Phantom, Tuple, DID, @@ -388,9 +386,61 @@ export type URI

= `${P}${string}` & }> export interface PrincipalParser { - parse(did: UCAN.DID): UCAN.Verifier + parse(did: UCAN.DID): Verifier +} + +/** + * Represents component that can create a signer from it's archive. Usually + * signer module would provide `from` function and therefor be implementation + * of this interface. + * Library also provides utility functions for combining multiple + * SignerImporters into one. + */ +export interface SignerImporter { + from(archive: SignerArchive): Self +} + +export interface Signer + extends UCANSigner { + /** + * Returns archive of this signer which is byte encoded form when signer key + * is extractable and is {@link SignerInfo} form otherwise. This allows a user + * to store non extractable archives in indexedDB and store extractable + * archives on disk, which matches general expectation that in browsers + * unextratable keys should be used and extractable keys in node. + * + * @example + * ```ts + * const save = async (signer: Signer) => { + * const archive = signer.toArchive() + * if (archive instanceof Uint8Array) { + * await fs.writeFile(KEY_PATH, archive) + * } else { + * await IDB_OBJECT_STORE.add(archive) + * } + * } + * ``` + */ + toArchive(): SignerArchive> +} + +export interface SignerInfo { + readonly did: ReturnType + readonly key: CryptoKey } +export type SignerArchive = + | ByteView + | SignerInfo + +export { Verifier } + export type InferInvokedCapability< C extends CapabilityParser> > = C extends CapabilityParser> ? T : never + +export type Intersection = (T extends any ? (i: T) => void : never) extends ( + i: infer I +) => void + ? I + : never diff --git a/packages/principal/package.json b/packages/principal/package.json index 4ce0c7a4..71d5da96 100644 --- a/packages/principal/package.json +++ b/packages/principal/package.json @@ -30,7 +30,8 @@ "@ipld/dag-ucan": "^4.0.0-beta", "@noble/ed25519": "^1.7.0", "@ucanto/interface": "^1.0.0", - "multiformats": "^9.8.1" + "multiformats": "^9.8.1", + "one-webcrypto": "^1.0.3" }, "devDependencies": { "@types/chai": "^4.3.3", @@ -52,6 +53,9 @@ ], "ed25519": [ "dist/src/ed25519.d.ts" + ], + "rsa": [ + "dist/src/rsa.d.ts" ] } }, @@ -59,6 +63,10 @@ "./ed25519": { "types": "./dist/src/ed25519.d.ts", "import": "./src/ed25519.js" + }, + "./rsa": { + "types": "./dist/src/rsa.d.ts", + "import": "./src/rsa.js" } }, "c8": { diff --git a/packages/principal/src/ed25519/signer.js b/packages/principal/src/ed25519/signer.js index 83cbabd3..f5311fb2 100644 --- a/packages/principal/src/ed25519/signer.js +++ b/packages/principal/src/ed25519/signer.js @@ -1,12 +1,14 @@ import * as ED25519 from '@noble/ed25519' import { varint } from 'multiformats' -import * as API from '@ucanto/interface' +import * as API from './type.js' import * as Verifier from './verifier.js' -import { base64pad, base64url } from 'multiformats/bases/base64' +import { base64pad } from 'multiformats/bases/base64' import * as Signature from '@ipld/dag-ucan/signature' export const code = 0x1300 export const name = Verifier.name +export const signatureAlgorithm = Verifier.signatureAlgorithm +export const signatureCode = Verifier.signatureCode const PRIVATE_TAG_SIZE = varint.encodingLength(code) const PUBLIC_TAG_SIZE = varint.encodingLength(Verifier.code) @@ -15,20 +17,16 @@ const SIZE = PRIVATE_TAG_SIZE + KEY_SIZE + PUBLIC_TAG_SIZE + KEY_SIZE export const PUB_KEY_OFFSET = PRIVATE_TAG_SIZE + KEY_SIZE -/** - * @typedef {API.Signer<"key", typeof Signature.EdDSA> & Uint8Array & { verifier: API.Verifier<"key", typeof Signature.EdDSA> }} Signer - */ - /** * Generates new issuer by generating underlying ED25519 keypair. - * @returns {Promise} + * @returns {Promise} */ export const generate = () => derive(ED25519.utils.randomPrivateKey()) /** * Derives issuer from 32 byte long secret key. * @param {Uint8Array} secret - * @returns {Promise} + * @returns {Promise} */ export const derive = async secret => { if (secret.byteLength !== KEY_SIZE) { @@ -49,9 +47,21 @@ export const derive = async secret => { return signer } +/** + * @param {API.SignerArchive>} archive + * @returns {API.EdSigner} + */ +export const from = archive => { + if (archive instanceof Uint8Array) { + return decode(archive) + } else { + throw new Error(`Unsupported archive format`) + } +} + /** * @param {Uint8Array} bytes - * @returns {Signer} + * @returns {API.EdSigner} */ export const decode = bytes => { if (bytes.byteLength !== SIZE) { @@ -80,31 +90,40 @@ export const decode = bytes => { } /** - * @param {Signer} signer - * @return {API.ByteView} + * @param {API.EdSigner} signer + * @return {API.ByteView} */ -export const encode = signer => signer +export const encode = signer => signer.toArchive() /** * @template {string} Prefix - * @param {Signer} signer + * @param {API.EdSigner} signer * @param {API.MultibaseEncoder} [encoder] */ -export const format = (signer, encoder) => (encoder || base64pad).encode(signer) +export const format = (signer, encoder) => + (encoder || base64pad).encode(encode(signer)) /** * @template {string} Prefix * @param {string} principal * @param {API.MultibaseDecoder} [decoder] - * @returns {Signer} + * @returns {API.EdSigner} */ export const parse = (principal, decoder) => decode((decoder || base64pad).decode(principal)) /** - * @implements {API.Signer<'key', typeof Signature.EdDSA>} + * @implements {API.EdSigner} */ class Ed25519Signer extends Uint8Array { + /** @type {typeof code} */ + get code() { + return code + } + get signer() { + return this + } + /** @type {API.EdVerifier} */ get verifier() { const bytes = new Uint8Array(this.buffer, PRIVATE_TAG_SIZE + KEY_SIZE) const verifier = Verifier.decode(bytes) @@ -149,6 +168,15 @@ class Ed25519Signer extends Uint8Array { 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 'EdDSA' @@ -156,4 +184,8 @@ class Ed25519Signer extends Uint8Array { get signatureCode() { return Signature.EdDSA } + + toArchive() { + return this + } } diff --git a/packages/principal/src/ed25519/type.js b/packages/principal/src/ed25519/type.js new file mode 100644 index 00000000..e69de29b diff --git a/packages/principal/src/ed25519/type.ts b/packages/principal/src/ed25519/type.ts new file mode 100644 index 00000000..1b2f4117 --- /dev/null +++ b/packages/principal/src/ed25519/type.ts @@ -0,0 +1,24 @@ +import { Signer, Verifier, ByteView, UCAN, Await } from '@ucanto/interface' +import * as Signature from '@ipld/dag-ucan/signature' + +export * from '@ucanto/interface' + +type CODE = typeof Signature.EdDSA +type ALG = 'EdDSA' + +export interface EdSigner + extends Signer, + UCAN.Verifier { + readonly signer: EdSigner + readonly verifier: EdVerifier + + readonly code: 0x1300 + toArchive(): ByteView> +} + +export interface EdVerifier + extends Verifier { + readonly code: 0xed + readonly signatureCode: CODE + readonly signatureAlgorithm: ALG +} diff --git a/packages/principal/src/ed25519/verifier.js b/packages/principal/src/ed25519/verifier.js index 158eae13..776bb186 100644 --- a/packages/principal/src/ed25519/verifier.js +++ b/packages/principal/src/ed25519/verifier.js @@ -1,13 +1,14 @@ import * as DID from '@ipld/dag-ucan/did' import * as ED25519 from '@noble/ed25519' import { varint } from 'multiformats' -import * as API from '@ucanto/interface' +import * as API from './type.js' import * as Signature from '@ipld/dag-ucan/signature' import { base58btc } from 'multiformats/bases/base58' export const code = 0xed +export const name = 'Ed25519' export const signatureCode = Signature.EdDSA -export const name = 'Ed25519' +export const signatureAlgorithm = 'EdDSA' const PUBLIC_TAG_SIZE = varint.encodingLength(code) const SIZE = 32 + PUBLIC_TAG_SIZE @@ -27,7 +28,7 @@ export const parse = did => decode(DID.parse(did)) * corresponding `Principal` that can be used to verify signatures. * * @param {Uint8Array} bytes - * @returns {Verifier} + * @returns {API.EdVerifier} */ export const decode = bytes => { const [algorithm] = varint.decode(bytes) @@ -40,11 +41,7 @@ export const decode = bytes => { `Expected Uint8Array with byteLength ${SIZE}, instead got Uint8Array with byteLength ${bytes.byteLength}` ) } else { - return new Ed25519Principal( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength - ) + return new Ed25519Verifier(bytes.buffer, bytes.byteOffset, bytes.byteLength) } } @@ -64,10 +61,21 @@ export const format = principal => DID.format(principal) export const encode = principal => DID.encode(principal) /** - * @implements {API.Verifier<"key", typeof Signature.EdDSA>} - * @implements {API.Principal<"key">} + * @implements {API.EdVerifier} */ -class Ed25519Principal extends Uint8Array { +class Ed25519Verifier extends Uint8Array { + /** @type {typeof code} */ + get code() { + return code + } + /** @type {typeof signatureCode} */ + get signatureCode() { + return signatureCode + } + /** @type {typeof signatureAlgorithm} */ + get signatureAlgorithm() { + return signatureAlgorithm + } /** * Raw public key without a multiformat code. * diff --git a/packages/principal/src/lib.js b/packages/principal/src/lib.js index ecee9f3c..dc83ca89 100644 --- a/packages/principal/src/lib.js +++ b/packages/principal/src/lib.js @@ -1 +1,9 @@ -export * as ed25519 from './ed25519.js' +import * as ed25519 from './ed25519.js' +import * as RSA from './rsa.js' +import { create as createVerifier } from './verifier.js' +import { create as createSigner } from './signer.js' + +export const Verifier = createVerifier([ed25519.Verifier, RSA.Verifier]) +export const Signer = createSigner([ed25519, RSA]) + +export { ed25519, RSA } diff --git a/packages/principal/src/multiformat.js b/packages/principal/src/multiformat.js new file mode 100644 index 00000000..fca5c851 --- /dev/null +++ b/packages/principal/src/multiformat.js @@ -0,0 +1,35 @@ +import { varint } from 'multiformats' + +/** + * + * @param {number} code + * @param {Uint8Array} bytes + */ +export const tagWith = (code, bytes) => { + const offset = varint.encodingLength(code) + const multiformat = new Uint8Array(bytes.byteLength + offset) + varint.encodeTo(code, multiformat, 0) + multiformat.set(bytes, offset) + + return multiformat +} + +/** + * @param {number} code + * @param {Uint8Array} source + * @param {number} byteOffset + * @returns + */ +export const untagWith = (code, source, byteOffset = 0) => { + const bytes = byteOffset !== 0 ? source.subarray(byteOffset) : source + const [tag, size] = varint.decode(bytes) + if (tag !== code) { + throw new Error( + `Expected multiformat with 0x${code.toString( + 16 + )} tag instead got 0x${tag.toString(16)}` + ) + } else { + return new Uint8Array(bytes.buffer, bytes.byteOffset + size) + } +} diff --git a/packages/principal/src/rsa.js b/packages/principal/src/rsa.js new file mode 100644 index 00000000..32363aa8 --- /dev/null +++ b/packages/principal/src/rsa.js @@ -0,0 +1,321 @@ +import { webcrypto } from 'one-webcrypto' +import { base58btc } from 'multiformats/bases/base58' +import * as API from './rsa/type.js' +import * as DID from '@ipld/dag-ucan/did' +import { tagWith, untagWith } from './multiformat.js' +import * as Signature from '@ipld/dag-ucan/signature' +import * as SPKI from './rsa/spki.js' +import * as PKCS8 from './rsa/pkcs8.js' +import * as PrivateKey from './rsa/private-key.js' +import * as PublicKey from './rsa/public-key.js' +export * from './rsa/type.js' + +export const name = 'RSA' +export const code = 0x1305 +const verifierCode = 0x1205 + +export const signatureCode = Signature.RS256 +export const signatureAlgorithm = 'RS256' + +const ALG = 'RSASSA-PKCS1-v1_5' +const HASH_ALG = 'SHA-256' +const KEY_SIZE = 2048 +const SALT_LEGNTH = 128 +const IMPORT_PARAMS = { + name: ALG, + hash: { name: HASH_ALG }, +} + +/** + * @param {object} options + * @param {number} [options.size] + * @param {boolean} [options.extractable] + * @returns {Promise} + */ +export const generate = async ({ + size = KEY_SIZE, + extractable = false, +} = {}) => { + // We start by generate an RSA keypair using web crypto API. + const { publicKey, privateKey } = await webcrypto.subtle.generateKey( + { + name: ALG, + modulusLength: size, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: HASH_ALG }, + }, + + extractable, + ['sign', 'verify'] + ) + + // Next we need to encode public key, because `RSAVerifier` uses it to + // for implementing a `did()` method. To do this we first export + // Subject Public Key Info (SPKI) using web crypto API. + const spki = await webcrypto.subtle.exportKey('spki', publicKey) + // Then we extract public key from the SPKI and tag it with RSA public key + // multicode + const publicBytes = tagWith(verifierCode, SPKI.decode(new Uint8Array(spki))) + // Now that we have publicKey and it's multiformat representation we can + // create a verifier. + const verifier = new RSAVerifier({ bytes: publicBytes, publicKey }) + + // If we generated non extractable key we just wrap actual keys and verifier + // in the RSASigner view. + if (!extractable) { + return new UnextractableRSASigner({ + privateKey, + verifier, + }) + } + // Otherwise we export key in Private Key Cryptography Standards (PKCS) + // format and extract a bytes corresponding to the private key, which + // we tag with RSA private key multiformat code. With both binary and actual + // key representation we create a RSASigner view. + // Please note that do key export flow during generation so that we can: + // 1. Guarantee that it will be exportable. + // 2. Make `export` method sync. + else { + const pkcs8 = await webcrypto.subtle.exportKey('pkcs8', privateKey) + const bytes = tagWith(code, PKCS8.decode(new Uint8Array(pkcs8))) + return new ExtractableRSASigner({ + privateKey, + bytes, + verifier, + }) + } +} + +/** + * @param {API.SignerArchive>} archive + * @returns {API.RSASigner} + */ +export const from = archive => { + if (archive instanceof Uint8Array) { + return decode(archive) + } else { + return new UnextractableRSASigner({ + privateKey: archive.key, + verifier: RSAVerifier.parse(archive.did), + }) + } +} + +/** + * @param {EncodedSigner} bytes + * @returns {API.RSASigner} + */ +export const decode = bytes => { + // First we decode RSA key data from the private key with multicode tag. + const rsa = PrivateKey.decode(untagWith(code, bytes)) + // Then we encode RSA key data as public key with multicode tag. + const publicBytes = tagWith(verifierCode, PublicKey.encode(rsa)) + + return new ExtractableRSASigner({ + bytes, + privateKey: webcrypto.subtle.importKey( + 'pkcs8', + PKCS8.encode(untagWith(code, bytes)), + IMPORT_PARAMS, + true, + ['sign'] + ), + + verifier: RSAVerifier.decode(publicBytes), + }) +} + +/** + * @implements {API.RSAVerifier} + */ +class RSAVerifier { + /** + * @param {object} options + * @param {API.Await} options.publicKey + * @param {API.ByteView} options.bytes + */ + constructor({ publicKey, bytes }) { + /** @private */ + this.publicKey = publicKey + /** @private */ + this.bytes = bytes + } + + /** + * @param {API.ByteView} bytes + * @returns {API.RSAVerifier} + */ + static decode(bytes) { + return new this({ + bytes, + publicKey: webcrypto.subtle.importKey( + 'spki', + SPKI.encode(untagWith(verifierCode, bytes)), + IMPORT_PARAMS, + true, + ['verify'] + ), + }) + } + /** + * @param {API.DID} did + * @returns {API.RSAVerifier} + */ + static parse(did) { + return RSAVerifier.decode(/** @type {Uint8Array} */ (DID.parse(did))) + } + + /** @type {typeof verifierCode} */ + get code() { + return verifierCode + } + /** + * @type {typeof signatureCode} + */ + get signatureCode() { + return signatureCode + } + /** + * @type {typeof signatureAlgorithm} + */ + get signatureAlgorithm() { + return signatureAlgorithm + } + /** + * DID of the Principal in `did:key` format. + * @returns {API.DID<"key">} + */ + did() { + return `did:key:${base58btc.encode(this.bytes)}` + } + + /** + * @template T + * @param {API.ByteView} payload + * @param {API.Signature} signature + * @returns {Promise} + */ + async verify(payload, signature) { + // if signature code does not match RS256 it's not signed by corresponding + // signer. + if (signature.code !== signatureCode) { + return false + } + + return webcrypto.subtle.verify( + { name: ALG, hash: { name: HASH_ALG } }, + await this.publicKey, + signature.raw, + payload + ) + } +} + +/** @type {API.PrincipalParser} */ +export const Verifier = RSAVerifier + +/** + * @typedef {API.ByteView>} EncodedSigner + */ + +class RSASigner { + /** + * @param {object} options + * @param {API.Await} options.privateKey + * @param {API.RSAVerifier} options.verifier + */ + constructor({ privateKey, verifier }) { + /** @readonly */ + this.verifier = verifier + /** @protected */ + this.privateKey = privateKey + } + get signer() { + // @ts-expect-error - we define export methods on subclasses + return /** @type {API.RSASigner} */ (this) + } + /** + * @type {typeof code} + */ + get code() { + return code + } + /** + * @type {typeof signatureCode} + */ + get signatureCode() { + return signatureCode + } + /** + * @type {typeof signatureAlgorithm} + */ + get signatureAlgorithm() { + return signatureAlgorithm + } + + did() { + return this.verifier.did() + } + /** + * @template T + * @param {API.ByteView} payload + * @param {API.Signature} signature + */ + verify(payload, signature) { + return this.verifier.verify(payload, signature) + } + /** + * @template T + * @param {API.ByteView} payload + * @returns {Promise>} + */ + async sign(payload) { + const buffer = await webcrypto.subtle.sign( + { name: ALG, saltLength: SALT_LEGNTH }, + await this.privateKey, + payload + ) + + return Signature.create(signatureCode, new Uint8Array(buffer)) + } +} + +/** + * @implements {API.RSASigner} + */ +class ExtractableRSASigner extends RSASigner { + /** + * @param {object} options + * @param {API.Await} options.privateKey + * @param {EncodedSigner} options.bytes + * @param {API.RSAVerifier} options.verifier + */ + constructor(options) { + super(options) + this.bytes = options.bytes + } + toArchive() { + return this.bytes + } +} + +/** + * @implements {API.RSASigner} + */ +class UnextractableRSASigner extends RSASigner { + /** + * @param {object} options + * @param {CryptoKey} options.privateKey + * @param {API.RSAVerifier} options.verifier + */ + constructor(options) { + super(options) + this.privateKey = options.privateKey + } + toArchive() { + return { + did: this.did(), + key: this.privateKey, + } + } +} diff --git a/packages/principal/src/rsa/asn1.js b/packages/principal/src/rsa/asn1.js new file mode 100644 index 00000000..16c290f3 --- /dev/null +++ b/packages/principal/src/rsa/asn1.js @@ -0,0 +1,329 @@ +/** + * ASN1 Tags as per https://luca.ntop.org/Teaching/Appunti/asn1.html + */ +const TAG_SIZE = 1 +export const INT_TAG = 0x02 +export const BITSTRING_TAG = 0x03 +export const OCTET_STRING_TAG = 0x04 +export const NULL_TAG = 0x05 +export const OBJECT_TAG = 0x06 +export const SEQUENCE_TAG = 0x30 + +export const UNUSED_BIT_PAD = 0x00 + +/** + * @param {number} length + * @returns {Uint8Array} + */ +export const encodeDERLength = length => { + if (length <= 127) { + return new Uint8Array([length]) + } + + /** @type {number[]} */ + const octets = [] + while (length !== 0) { + octets.push(length & 0xff) + length = length >>> 8 + } + octets.reverse() + return new Uint8Array([0x80 | (octets.length & 0xff), ...octets]) +} + +/** + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {{number: number, consumed: number}} + */ +export const readDERLength = (bytes, offset = 0) => { + if ((bytes[offset] & 0x80) === 0) { + return { number: bytes[offset], consumed: 1 } + } + + const numberBytes = bytes[offset] & 0x7f + /* c8 ignore next 5 */ + if (bytes.length < numberBytes + 1) { + throw new Error( + `ASN parsing error: Too few bytes. Expected encoded length's length to be at least ${numberBytes}` + ) + } + + let length = 0 + for (let i = 0; i < numberBytes; i++) { + length = length << 8 + length = length | bytes[offset + i + 1] + } + + return { number: length, consumed: numberBytes + 1 } +} + +/** + * @param {Uint8Array} input + * @param {number} expectedTag + * @param {number} position + * @returns {number} + */ +export const skip = (input, expectedTag, position) => { + const parsed = into(input, expectedTag, position) + return parsed.position + parsed.length +} + +/** + * @param {Uint8Array} input + * @param {number} expectedTag + * @param {number} offset + * @returns {{ position: number, length: number }} + */ +export const into = (input, expectedTag, offset) => { + const actualTag = input[offset] + /* c8 ignore next 7 */ + if (actualTag !== expectedTag) { + throw new Error( + `ASN parsing error: Expected tag 0x${expectedTag.toString( + 16 + )} at position ${offset}, but got 0x${actualTag.toString(16)}.` + ) + } + + // length + const length = readDERLength(input, offset + TAG_SIZE) + const position = offset + TAG_SIZE + length.consumed + + // content + return { position, length: length.number } +} + +/** + * @param {Uint8Array} input + */ +export const encodeBitString = input => { + // encode input length + 1 for unused bit pad + const length = encodeDERLength(input.byteLength + 1) + // allocate a buffer of desired size + const bytes = new Uint8Array( + TAG_SIZE + // ASN_BITSTRING_TAG + length.byteLength + + 1 + // amount of unused bits at the end of our bitstring + input.byteLength + ) + + let byteOffset = 0 + // write bytestring tag + bytes[byteOffset] = BITSTRING_TAG + byteOffset += TAG_SIZE + + // write length of the bytestring + bytes.set(length, byteOffset) + byteOffset += length.byteLength + + // write unused bits at the end of our bitstring + bytes[byteOffset] = UNUSED_BIT_PAD + byteOffset += 1 + + // write actual data into bitstring + bytes.set(input, byteOffset) + + return bytes +} + +/** + * @param {Uint8Array} input + */ +export const encodeOctetString = input => { + // encode input length + const length = encodeDERLength(input.byteLength) + // allocate a buffer of desired size + const bytes = new Uint8Array(TAG_SIZE + length.byteLength + input.byteLength) + + let byteOffset = 0 + // write octet string tag + bytes[byteOffset] = OCTET_STRING_TAG + byteOffset += TAG_SIZE + + // write octet string length + bytes.set(length, byteOffset) + byteOffset += length.byteLength + + // write actual data into bitstring + bytes.set(input, byteOffset) + + return bytes +} + +/** + * @param {Uint8Array[]} sequence + */ +export const encodeSequence = sequence => { + // calculate bytelength for all the parts + let byteLength = 0 + for (const item of sequence) { + byteLength += item.byteLength + } + + // encode sequence byte length + const length = encodeDERLength(byteLength) + + // allocate the buffer to write sequence into + const bytes = new Uint8Array(TAG_SIZE + length.byteLength + byteLength) + + let byteOffset = 0 + + // write the sequence tag + bytes[byteOffset] = SEQUENCE_TAG + byteOffset += TAG_SIZE + + // write sequence length + bytes.set(length, byteOffset) + byteOffset += length.byteLength + + // write each item in the sequence + for (const item of sequence) { + bytes.set(item, byteOffset) + byteOffset += item.byteLength + } + + return bytes +} + +/** + * @param {Uint8Array} bytes + * @param {number} offset + */ +export const readSequence = (bytes, offset = 0) => { + const { position, length } = into(bytes, SEQUENCE_TAG, offset) + + return new Uint8Array(bytes.buffer, bytes.byteOffset + position, length) +} + +/** + * @param {Uint8Array} input + */ +export const encodeInt = input => { + const extra = input.byteLength === 0 || input[0] & 0x80 ? 1 : 0 + + // encode input length + const length = encodeDERLength(input.byteLength + extra) + // allocate a buffer of desired size + const bytes = new Uint8Array( + TAG_SIZE + // INT_TAG + length.byteLength + + input.byteLength + + extra + ) + + let byteOffset = 0 + // write octet string tag + bytes[byteOffset] = INT_TAG + byteOffset += TAG_SIZE + + // write int length + bytes.set(length, byteOffset) + byteOffset += length.byteLength + + // add 0 if the most-significant bit is set + if (extra > 0) { + bytes[byteOffset] = UNUSED_BIT_PAD + byteOffset += extra + } + + // write actual data into bitstring + bytes.set(input, byteOffset) + + return bytes +} + +/** + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {number} + */ + +export const enterSequence = (bytes, offset = 0) => + into(bytes, SEQUENCE_TAG, offset).position + +/** + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {number} + */ +export const skipSequence = (bytes, offset = 0) => + skip(bytes, SEQUENCE_TAG, offset) + +/** + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {number} + */ +export const skipInt = (bytes, offset = 0) => skip(bytes, INT_TAG, offset) + +/** + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {Uint8Array} + */ +export const readBitString = (bytes, offset = 0) => { + const { position, length } = into(bytes, BITSTRING_TAG, offset) + const tag = bytes[position] + /* c8 ignore next 5 */ + if (tag !== UNUSED_BIT_PAD) { + throw new Error( + `Can not read bitstring, expected length to be multiple of 8, but got ${tag} unused bits in last byte.` + ) + } + + return new Uint8Array( + bytes.buffer, + bytes.byteOffset + position + 1, + length - 1 + ) +} + +/** + * @param {Uint8Array} bytes + * @param {number} byteOffset + * @returns {Uint8Array} + */ +export const readInt = (bytes, byteOffset = 0) => { + const { position, length } = into(bytes, INT_TAG, byteOffset) + let delta = 0 + + // drop leading 0s + while (bytes[position + delta] === 0) { + delta++ + } + + return new Uint8Array( + bytes.buffer, + bytes.byteOffset + position + delta, + length - delta + ) +} + +/** + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {Uint8Array} + */ +export const readOctetString = (bytes, offset = 0) => { + const { position, length } = into(bytes, OCTET_STRING_TAG, offset) + + return new Uint8Array(bytes.buffer, bytes.byteOffset + position, length) +} + +/** + * @typedef {(bytes:Uint8Array, offset:number) => Uint8Array} Reader + * @param {[Reader, ...Reader[]]} readers + * @param {Uint8Array} source + * @param {number} byteOffset + */ +export const readSequenceWith = (readers, source, byteOffset = 0) => { + const results = [] + const sequence = readSequence(source, byteOffset) + let offset = 0 + for (const read of readers) { + const chunk = read(sequence, offset) + results.push(chunk) + offset = chunk.byteOffset + chunk.byteLength - sequence.byteOffset + } + return results +} diff --git a/packages/principal/src/rsa/pkcs8.js b/packages/principal/src/rsa/pkcs8.js new file mode 100644 index 00000000..a712f31a --- /dev/null +++ b/packages/principal/src/rsa/pkcs8.js @@ -0,0 +1,52 @@ +import * as API from '@ucanto/interface' +import { base64url } from 'multiformats/bases/base64' +import { + encodeSequence, + encodeOctetString, + enterSequence, + skipSequence, + skipInt, + readOctetString, +} from './asn1.js' + +const PKSC8_HEADER = new Uint8Array([ + // version + 2, 1, 0, + // privateKeyAlgorithm + 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, +]) +/** + * @typedef {import('./private-key').RSAPrivateKey} RSAPrivateKey + * @typedef {object} AlgorithmIdentifier + * @property {Uint8Array} version + * @property {Uint8Array} parameters + * + * @see https://datatracker.ietf.org/doc/html/rfc5208#section-5 + * @typedef {object} PrivateKeyInfo + * @property {API.ByteView} version + * @property {API.ByteView} privateKeyAlgorithm + * @property {API.ByteView} privateKey + * @property {API.ByteView} [attributes] + */ + +/** + * @param {API.ByteView} info + * @returns {API.ByteView} + */ +export const decode = info => { + let offset = 0 + // go into the top-level SEQUENCE + offset = enterSequence(info, offset) + offset = skipInt(info, offset) + offset = skipSequence(info, offset) + + // we expect the bitstring next + return readOctetString(info, offset) +} + +/** + * @param {API.ByteView} key + * @returns {API.ByteView} + */ +export const encode = key => + encodeSequence([PKSC8_HEADER, encodeOctetString(key)]) diff --git a/packages/principal/src/rsa/private-key.js b/packages/principal/src/rsa/private-key.js new file mode 100644 index 00000000..5a2563ca --- /dev/null +++ b/packages/principal/src/rsa/private-key.js @@ -0,0 +1,127 @@ +import * as API from '@ucanto/interface' +import { encodeSequence, readInt, readSequenceWith, encodeInt } from './asn1.js' +import { base64url } from 'multiformats/bases/base64' +import * as PKCS8 from './pkcs8.js' +import * as SPKI from './spki.js' +import * as PublicKey from './public-key.js' + +export const code = 0x1305 +const VERSION = new Uint8Array() + +/** + * @see https://datatracker.ietf.org/doc/html/rfc3447#appendix-A.1.2 + * @typedef {object} RSAPrivateKey + * @property {Uint8Array} v + * @property {Uint8Array} n + * @property {Uint8Array} e + * @property {Uint8Array} d + * @property {Uint8Array} p + * @property {Uint8Array} q + * @property {Uint8Array} dp + * @property {Uint8Array} dq + * @property {Uint8Array} qi + */ + +/** + * Takes private-key information in [Private-Key Information Syntax](https://datatracker.ietf.org/doc/html/rfc5208#section-5) + * and extracts all the fields as per [RSA private key syntax](https://datatracker.ietf.org/doc/html/rfc3447#appendix-A.1.2) + * + * + * @param {API.ByteView} source + * @param {number} byteOffset + * @returns {RSAPrivateKey} + */ +export const decode = (source, byteOffset = 0) => { + const [v, n, e, d, p, q, dp, dq, qi] = readSequenceWith( + [ + readInt, + readInt, + readInt, + readInt, + readInt, + readInt, + readInt, + readInt, + readInt, + ], + source, + byteOffset + ) + + return { v, n, e, d, p, q, dp, dq, qi } +} + +/** + * @param {RSAPrivateKey} key + * @returns {API.ByteView} + */ +export const encode = ({ v, n, e, d, p, q, dp, dq, qi }) => { + return encodeSequence([ + encodeInt(v), + encodeInt(n), + encodeInt(e), + encodeInt(d), + encodeInt(p), + encodeInt(q), + encodeInt(dp), + encodeInt(dq), + encodeInt(qi), + ]) +} + +/** + * @param {RSAPrivateKey} key + * @returns {JsonWebKey} + */ +export const toJWK = ({ n, e, d, p, q, dp, dq, qi }) => ({ + kty: 'RSA', + alg: 'RS256', + key_ops: ['sign'], + ext: true, + n: base64url.baseEncode(n), + e: base64url.baseEncode(e), + d: base64url.baseEncode(d), + p: base64url.baseEncode(p), + q: base64url.baseEncode(q), + dp: base64url.baseEncode(dp), + dq: base64url.baseEncode(dq), + qi: base64url.baseEncode(qi), +}) + +/** + * @param {JsonWebKey} key + * @returns {RSAPrivateKey} + */ +export const fromJWK = ({ n, e, d, p, q, dp, dq, qi }) => ({ + v: VERSION, + n: base64urlDecode(n), + e: base64urlDecode(e), + d: base64urlDecode(d), + p: base64urlDecode(p), + q: base64urlDecode(q), + dp: base64urlDecode(dp), + dq: base64urlDecode(dq), + qi: base64urlDecode(qi), +}) + +/** + * @param {RSAPrivateKey} key + */ +export const toPKCS8 = key => PKCS8.encode(encode(key)) + +/** + * @param {API.ByteView} info + */ +export const fromPKCS8 = info => decode(PKCS8.decode(info)) + +/** + * @param {RSAPrivateKey} key + */ +export const toSPKI = key => SPKI.encode(PublicKey.encode(key)) + +/** + * + * @param {string|undefined} input + * @returns + */ +const base64urlDecode = (input = '') => base64url.baseDecode(input) diff --git a/packages/principal/src/rsa/public-key.js b/packages/principal/src/rsa/public-key.js new file mode 100644 index 00000000..b27f020b --- /dev/null +++ b/packages/principal/src/rsa/public-key.js @@ -0,0 +1,70 @@ +import * as API from '@ucanto/interface' +import { encodeSequence, readInt, encodeInt, readSequenceWith } from './asn1.js' +import * as SPKI from './spki.js' +import { base64url } from 'multiformats/bases/base64' +/** + * RSA public key represenatation + * @see https://datatracker.ietf.org/doc/html/rfc3447#appendix-A.1 + * + * @typedef {object} RSAPublicKey + * @property {API.ByteView} n + * @property {API.ByteView} e + */ + +/** + * Takes private-key information in [Private-Key Information Syntax](https://datatracker.ietf.org/doc/html/rfc5208#section-5) + * and extracts all the fields as per [RSA private key syntax](https://datatracker.ietf.org/doc/html/rfc3447#appendix-A.1.2) + * + * + * @param {API.ByteView} key + * @param {number} byteOffset + * @returns {RSAPublicKey} + */ +export const decode = (key, byteOffset = 0) => { + const [n, e] = readSequenceWith([readInt, readInt], key, byteOffset) + + return { n, e } +} + +/** + * @param {RSAPublicKey} key + * @returns {API.ByteView} + */ +export const encode = ({ n, e }) => encodeSequence([encodeInt(n), encodeInt(e)]) + +/** + * @param {RSAPublicKey} key + */ +export const toSPKI = key => SPKI.encode(encode(key)) + +/** + * @param {API.ByteView} info + */ +export const fromSPKI = info => decode(SPKI.decode(info)) + +/** + * @param {RSAPublicKey} key + * @returns {JsonWebKey} + */ +export const toJWK = ({ n, e }) => ({ + kty: 'RSA', + alg: 'RS256', + key_ops: ['verify'], + ext: true, + n: base64url.baseEncode(n), + e: base64url.baseEncode(e), +}) + +/** + * @param {JsonWebKey} jwk + * @returns {RSAPublicKey} + */ +export const fromJWK = ({ n, e }) => ({ + n: base64urlDecode(n), + e: base64urlDecode(e), +}) + +/** + * @param {string|undefined} input + */ +const base64urlDecode = (input = '') => base64url.baseDecode(input) diff --git a/packages/principal/src/rsa/spki.js b/packages/principal/src/rsa/spki.js new file mode 100644 index 00000000..221dbc53 --- /dev/null +++ b/packages/principal/src/rsa/spki.js @@ -0,0 +1,66 @@ +import * as API from '@ucanto/interface' +import { + encodeSequence, + encodeBitString, + enterSequence, + skipSequence, + readBitString, +} from './asn1.js' + +/** + * @typedef {import('./public-key.js').RSAPublicKey} RSAPublicKey + */ +/** + * Described in RFC 5208 Section 4.1: https://tools.ietf.org/html/rfc5280#section-4.1 + * ``` + * SubjectPublicKeyInfo ::= SEQUENCE { + * algorithm AlgorithmIdentifier, + * subjectPublicKey BIT STRING } + * ``` + * + * @typedef {object} SubjectPublicKeyInfo + * @property {API.ByteView} algorithm + * @property {API.ByteView} subjectPublicKey + * @typedef {import('./pkcs8.js').AlgorithmIdentifier} AlgorithmIdentifier + */ + +/** + * The ASN.1 DER encoded header that needs to be added to an + * ASN.1 DER encoded RSAPublicKey to make it a SubjectPublicKeyInfo. + * + * This byte sequence is always the same. + * + * A human-readable version of this as part of a dumpasn1 dump: + * + * SEQUENCE { + * OBJECT IDENTIFIER rsaEncryption (1 2 840 113549 1 1 1) + * NULL + * } + * + * See https://github.com/ucan-wg/ts-ucan/issues/30 + */ +export const SPKI_PARAMS_ENCODED = new Uint8Array([ + 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, +]) + +/** + * @param {API.ByteView} key + * @returns {API.ByteView} + */ +export const encode = key => + encodeSequence([SPKI_PARAMS_ENCODED, encodeBitString(key)]) + +/** + * + * @param {API.ByteView} info + * @returns {API.ByteView} + */ +export const decode = info => { + // go into the top-level SEQUENCE + const offset = enterSequence(info, 0) + // skip the header we expect (SKPI_PARAMS_ENCODED) + const keyOffset = skipSequence(info, offset) + + // we expect the bitstring next + return readBitString(info, keyOffset) +} diff --git a/packages/principal/src/rsa/type.js b/packages/principal/src/rsa/type.js new file mode 100644 index 00000000..e69de29b diff --git a/packages/principal/src/rsa/type.ts b/packages/principal/src/rsa/type.ts new file mode 100644 index 00000000..7061661d --- /dev/null +++ b/packages/principal/src/rsa/type.ts @@ -0,0 +1,22 @@ +import { Signer, Verifier, ByteView, UCAN, Await } from '@ucanto/interface' + +export * from '@ucanto/interface' + +type CODE = 0xd01205 +type ALG = 'RS256' + +export interface RSASigner + extends Signer, + UCAN.Verifier { + readonly signer: RSASigner + readonly verifier: RSAVerifier + + readonly code: 0x1305 +} + +export interface RSAVerifier + extends Verifier { + readonly code: 0x1205 + readonly signatureCode: CODE + readonly signatureAlgorithm: ALG +} diff --git a/packages/principal/src/signer.js b/packages/principal/src/signer.js new file mode 100644 index 00000000..8296f251 --- /dev/null +++ b/packages/principal/src/signer.js @@ -0,0 +1,24 @@ +import * as API from '@ucanto/interface' + +/** + * @template {[API.SignerImporter, ...API.SignerImporter[]]} Importers + * @param {Importers} importers + */ +export const create = importers => { + const from = /** @type {API.Intersection} */ ( + /** + * @param {API.SignerArchive} archive + * @returns {API.Signer} + */ + archive => { + for (const importer of importers) { + try { + return importer.from(archive) + } catch (_) {} + } + throw new Error(`Unsupported signer`) + } + ) + + return { create, from } +} diff --git a/packages/principal/src/verifier.js b/packages/principal/src/verifier.js new file mode 100644 index 00000000..e3371685 --- /dev/null +++ b/packages/principal/src/verifier.js @@ -0,0 +1,20 @@ +import * as API from '@ucanto/interface' + +/** + * @param {API.PrincipalParser[]} options + */ +export const create = options => ({ + create, + /** + * @param {API.DID} did + * @return {API.Verifier} + */ + parse: did => { + for (const option of options) { + try { + return option.parse(did) + } catch (_) {} + } + throw new Error(`Unsupported principal with DID ${did}`) + }, +}) diff --git a/packages/principal/test/ed25519.spec.js b/packages/principal/test/ed25519.spec.js new file mode 100644 index 00000000..8dc14602 --- /dev/null +++ b/packages/principal/test/ed25519.spec.js @@ -0,0 +1,186 @@ +import { ed25519, ed25519 as Lib } from '../src/lib.js' +import { assert } from 'chai' +import { sha256 } from 'multiformats/hashes/sha2' +import { varint } from 'multiformats' + +describe('signing principal', () => { + const { Signer } = Lib + + it('exports', () => { + assert.equal(Lib.code, 0x1300) + assert.equal(Lib.name, 'Ed25519') + assert.equal(Lib.signatureAlgorithm, 'EdDSA') + assert.equal(Lib.signatureCode, 0xd0ed) + assert.equal(typeof Lib.derive, 'function') + assert.equal(typeof Lib.generate, 'function') + + assert.equal(typeof Lib.Verifier, 'object') + assert.equal(typeof Lib.Signer, 'object') + }) + + it('generate', async () => { + const signer = await Lib.generate() + assert.ok(signer.did().startsWith('did:key')) + assert.equal(signer.code, 0x1300) + assert.equal(signer.signatureCode, 0xd0ed) + assert.equal(signer.signatureAlgorithm, 'EdDSA') + assert.equal(signer.signer, signer) + assert.equal(signer.verifier.code, 0xed) + assert.equal(signer.verifier.signatureCode, 0xd0ed) + assert.equal(signer.verifier.signatureAlgorithm, 'EdDSA') + + const payload = await sha256.encode(new TextEncoder().encode('hello world')) + const signature = await signer.sign(payload) + + const verifier = Lib.Verifier.parse(signer.did()) + assert.equal( + await verifier.verify(payload, signature), + true, + 'signer can verify signature' + ) + assert.equal(await signer.verify(payload, signature), true) + + assert.equal(signer.signatureAlgorithm, 'EdDSA') + assert.equal(signer.signatureCode, 0xd0ed) + assert.equal(signer.did(), verifier.did()) + }) + + it('derive', async () => { + const original = await Lib.generate() + // @ts-expect-error - secret is not defined by interface + const derived = await Lib.derive(original.secret) + + // @ts-expect-error - secret is not defined by interface + assert.deepEqual(original.secret, derived.secret) + assert.equal(original.did(), derived.did()) + }) + + it('derive throws on bad input', async () => { + // @ts-expect-error - secret is not defined by interface + const { secret } = await Lib.generate() + try { + await Lib.derive(secret.subarray(1)) + assert.fail('Expected to throw') + } catch (error) { + assert.match(String(error), /Expected Uint8Array with byteLength of 32/) + } + }) + + it('SigningPrincipal.decode', async () => { + const signer = await Lib.generate() + const bytes = Signer.encode(signer) + + assert.deepEqual(Signer.decode(signer.toArchive()), signer) + + const invalid = new Uint8Array(signer.toArchive()) + varint.encodeTo(4, invalid, 0) + assert.throws(() => Signer.decode(invalid), /must be a multiformat with/) + + assert.throws( + () => Signer.decode(signer.toArchive().slice(0, 32)), + /Expected Uint8Array with byteLength/ + ) + + const malformed = new Uint8Array(signer.toArchive()) + // @ts-ignore + varint.encodeTo(4, malformed, Signer.PUB_KEY_OFFSET) + + assert.throws(() => Signer.decode(malformed), /must contain public key/) + }) + + it('SigningPrincipal decode encode roundtrip', async () => { + const signer = await Lib.generate() + + assert.deepEqual(Signer.decode(Signer.encode(signer)), signer) + }) + + it('SigningPrincipal.format', async () => { + const signer = await Lib.generate() + + assert.deepEqual(Signer.parse(Signer.format(signer)), signer) + }) + + it('SigningPrincipal.did', async () => { + const signer = await Lib.generate() + + assert.equal(signer.did().startsWith('did:key:'), true) + }) +}) + +describe('principal', () => { + const { Verifier, Signer } = Lib + + it('exports', async () => { + assert.equal(Verifier, await import('../src/ed25519/verifier.js')) + assert.equal(Verifier.code, 0xed) + assert.equal(Verifier.signatureAlgorithm, 'EdDSA') + }) + + it('Verifier.parse', async () => { + const signer = await Lib.generate() + const verifier = Verifier.parse(signer.did()) + const bytes = signer.toArchive() + + assert.deepEqual( + new Uint8Array(bytes.buffer, bytes.byteOffset + Signer.PUB_KEY_OFFSET), + Object(verifier) + ) + assert.equal(verifier.did(), signer.did()) + }) + + it('Verifier.decode', async () => { + const signer = await Lib.generate() + const bytes = signer.toArchive() + + const verifier = new Uint8Array( + bytes.buffer, + bytes.byteOffset + Signer.PUB_KEY_OFFSET + ) + assert.deepEqual(Object(Verifier.decode(verifier)), verifier) + assert.throws( + () => Verifier.decode(signer.toArchive()), + /key algorithm with multicode/ + ) + + assert.throws( + () => Verifier.decode(verifier.slice(0, 32)), + /Expected Uint8Array with byteLength/ + ) + }) + + it('Verifier.format', async () => { + const signer = await Lib.generate() + const verifier = Verifier.parse(signer.did()) + + assert.deepEqual(Verifier.format(verifier), signer.did()) + }) + + it('Verifier.encode', async () => { + const { verifier } = await Lib.generate() + + const bytes = Verifier.encode(verifier) + assert.deepEqual(Verifier.decode(bytes), verifier) + }) + + it('signer toArchive', async () => { + const signer = await Lib.generate() + + assert.equal(signer.toArchive(), Signer.encode(signer)) + }) + + it('can parse keys with forward slash', async () => { + // @see https://github.com/web3-storage/ucanto/issues/85 + const key = + 'MgCYY9lYduqC9rrtD1YvZzcEfPCFBaYsTe0T+8RLLBawPWu0BAaNqeI86jQPsOeSaZ7p+ZPWGFqggfvSMFw+AJ7BH8/U=' + const ed = ed25519.parse(key) + assert.equal( + ed.did(), + 'did:key:z6MkeZeyji49ZVbinyPENzhZMVML7s79bbjN9K4iNFBsFkdr' + ) + + assert.equal(ed25519.format(ed), key) + + const payload = new TextEncoder().encode('hello world') + assert.equal(await ed.verify(payload, await ed.sign(payload)), true) + }) +}) diff --git a/packages/principal/test/lib.spec.js b/packages/principal/test/lib.spec.js index 80a23a6e..749d7993 100644 --- a/packages/principal/test/lib.spec.js +++ b/packages/principal/test/lib.spec.js @@ -1,148 +1,84 @@ -import { ed25519 as Lib } from '../src/lib.js' +import * as API from '@ucanto/interface' +import { Verifier, Signer, ed25519, RSA } from '../src/lib.js' import { assert } from 'chai' -import { sha256 } from 'multiformats/hashes/sha2' -import { varint } from 'multiformats' -describe('signing principal', () => { - const { Signer } = Lib +const utf8 = new TextEncoder() +describe('PrincipalParser', () => { + it('parse & verify', async () => { + const ed = await ed25519.generate() + const rsa = await RSA.generate() - it('exports', () => { - assert.equal(Lib.name, 'Ed25519') - assert.equal(Lib.code, 0x1300) - assert.equal(typeof Lib.derive, 'function') - assert.equal(typeof Lib.generate, 'function') + const edp = Verifier.parse(ed.did()) - assert.equal(typeof Lib.Verifier, 'object') - assert.equal(typeof Lib.Signer, 'object') - }) - - it('generate', async () => { - const signer = await Lib.generate() - assert.ok(signer.did().startsWith('did:key')) - assert.ok(signer instanceof Uint8Array) - - const payload = await sha256.encode(new TextEncoder().encode('hello world')) - const signature = await signer.sign(payload) - - const verifier = Lib.Verifier.parse(signer.did()) - assert.ok( - await verifier.verify(payload, signature), - 'signer can verify signature' - ) - - assert.equal(signer.signatureAlgorithm, 'EdDSA') - assert.equal(signer.signatureCode, 0xd0ed) - }) + const payload = utf8.encode('hello ed') - it('derive', async () => { - const original = await Lib.generate() - // @ts-expect-error - secret is not defined by interface - const derived = await Lib.derive(original.secret) + assert.equal(await edp.verify(payload, await ed.sign(payload)), true) + assert.equal(await edp.verify(payload, await rsa.sign(payload)), false) - // @ts-expect-error - secret is not defined by interface - assert.deepEqual(original.secret, derived.secret) - assert.equal(original.did(), derived.did()) + const rsap = Verifier.parse(rsa.did()) + assert.equal(await rsap.verify(payload, await ed.sign(payload)), false) + assert.equal(await rsap.verify(payload, await rsa.sign(payload)), true) }) - it('derive throws on bad input', async () => { - // @ts-expect-error - secret is not defined by interface - const { secret } = await Lib.generate() - try { - await Lib.derive(secret.subarray(1)) - assert.fail('Expected to throw') - } catch (error) { - assert.match(String(error), /Expected Uint8Array with byteLength of 32/) - } - }) - - it('SigningPrincipal.decode', async () => { - const signer = await Lib.generate() - const bytes = Signer.encode(signer) - - assert.deepEqual(Signer.decode(signer), signer) - - const invalid = new Uint8Array(signer) - varint.encodeTo(4, invalid, 0) - assert.throws(() => Signer.decode(invalid), /must be a multiformat with/) - + it('throws on unknown did', () => { assert.throws( - () => Signer.decode(signer.slice(0, 32)), - /Expected Uint8Array with byteLength/ + () => Verifier.parse('did:echo:boom'), + /Unsupported principal/ ) - - const malformed = new Uint8Array(signer) - // @ts-ignore - varint.encodeTo(4, malformed, Signer.PUB_KEY_OFFSET) - - assert.throws(() => Signer.decode(malformed), /must contain public key/) - }) - - it('SigningPrincipal decode encode roundtrip', async () => { - const signer = await Lib.generate() - - assert.deepEqual(Signer.decode(Signer.encode(signer)), signer) }) - it('SigningPrincipal.format', async () => { - const signer = await Lib.generate() + it('throws on invalid ed archive', async () => { + const ed = await ed25519.generate() + const rsa = await RSA.generate() - assert.deepEqual(Signer.parse(Signer.format(signer)), signer) - }) + const { key } = /** @type {API.SignerInfo} */ (rsa.toArchive()) - it('SigningPrincipal.did', async () => { - const signer = await Lib.generate() + const archive = { did: ed.did(), key } - assert.equal(signer.did().startsWith('did:key:'), true) + assert.throws(() => Signer.from(archive), /Unsupported signer/) }) -}) -describe('principal', () => { - const { Verifier, Signer } = Lib + it('ed decode & sign', async () => { + const ed = await ed25519.generate() - it('exports', async () => { - assert.equal(Verifier, await import('../src/ed25519/verifier.js')) - assert.equal(Verifier.code, 0xed) - assert.equal(Verifier.name, 'Ed25519') - }) - - it('Verifier.parse', async () => { - const signer = await Lib.generate() - const verifier = Verifier.parse(signer.did()) + const bytes = ed.toArchive() + const signer = Signer.from(bytes) + const payload = utf8.encode('hello ed') - assert.deepEqual( - new Uint8Array(signer.buffer, signer.byteOffset + Signer.PUB_KEY_OFFSET), - verifier + const signature = await signer.sign(payload) + assert.equal( + await ed.verify( + payload, + /** @type {API.Signature} */ ( + signature + ) + ), + true ) - assert.equal(verifier.did(), signer.did()) }) - it('Verifier.decode', async () => { - const signer = await Lib.generate() + it('rsa decode & sign', async () => { + const rsa = await RSA.generate({ extractable: true }) - const verifier = new Uint8Array( - signer.buffer, - signer.byteOffset + Signer.PUB_KEY_OFFSET - ) - assert.deepEqual(Verifier.decode(verifier), verifier) - assert.throws(() => Verifier.decode(signer), /key algorithm with multicode/) + const signer = Signer.from(rsa.toArchive()) + const payload = utf8.encode('hello ed') - assert.throws( - () => Verifier.decode(verifier.slice(0, 32)), - /Expected Uint8Array with byteLength/ + const signature = await signer.sign(payload) + assert.equal( + await rsa.verify( + payload, + /** @type {API.Signature} */ ( + signature + ) + ), + true ) }) - it('Verifier.format', async () => { - const signer = await Lib.generate() - const verifier = Verifier.parse(signer.did()) - - assert.deepEqual(Verifier.format(verifier), signer.did()) - }) - - it('Verifier.encode', async () => { - const { verifier } = await Lib.generate() - - const bytes = Verifier.encode(verifier) - assert.deepEqual(Verifier.decode(bytes), verifier) + it('throws on unknown signer', () => { + assert.throws( + () => Signer.from(new Uint8Array([1, 1, 1])), + /Unsupported signer/ + ) }) }) diff --git a/packages/principal/test/rsa.spec.js b/packages/principal/test/rsa.spec.js new file mode 100644 index 00000000..93e86268 --- /dev/null +++ b/packages/principal/test/rsa.spec.js @@ -0,0 +1,330 @@ +import * as RSA from '../src/rsa.js' +import * as PrivateKey from '../src/rsa/private-key.js' +import * as PublicKey from '../src/rsa/public-key.js' +import * as PKCS8 from '../src/rsa/pkcs8.js' +import * as multiformat from '../src/multiformat.js' +import { assert } from 'chai' +import { varint } from 'multiformats' +import { webcrypto } from 'one-webcrypto' + +export const utf8 = new TextEncoder() +describe('RSA', () => { + it('can generate non extractabel keypair', async () => { + const signer = await RSA.generate() + + assert.equal(signer.code, 0x1305) + assert.equal(signer.signatureCode, 0xd01205) + assert.equal(signer.signatureAlgorithm, 'RS256') + assert.match(signer.did(), /did:key:/) + assert.equal(typeof signer.toArchive, 'function') + assert.equal(typeof signer.verify, 'function') + assert.equal(typeof signer.sign, 'function') + + assert.equal(signer.signer, signer) + + const { verifier } = signer + assert.equal(typeof verifier.verify, 'function') + assert.equal(verifier.code, 0x1205) + assert.equal(verifier.signatureCode, 0xd01205) + assert.equal(verifier.signatureAlgorithm, 'RS256') + assert.equal(verifier.did(), signer.did()) + + const { key, did } = /** @type {RSA.SignerInfo} */ (signer.toArchive()) + assert.equal(did, signer.did()) + assert.equal(key.type, 'private') + assert.deepEqual(Object(key.algorithm), { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { + name: 'SHA-256', + }, + }) + assert.equal(key.extractable, false) + assert.deepEqual(key.usages, ['sign']) + }) + + it('can archive 🔁 restore unextractable', async () => { + const original = await RSA.generate() + const bytes = original.toArchive() + const restored = RSA.from(bytes) + const payload = utf8.encode('hello world') + + assert.equal( + await restored.verify(payload, await original.sign(payload)), + true + ) + + assert.equal( + await original.verify(payload, await restored.sign(payload)), + true + ) + }) + + it('can generate extractable keypair', async () => { + const signer = await RSA.generate({ extractable: true }) + assert.equal(signer.signatureCode, 0xd01205) + assert.equal(signer.signatureAlgorithm, 'RS256') + assert.match(signer.did(), /did:key:/) + assert.equal(signer, signer.signer) + assert.equal(typeof signer.toArchive, 'function') + assert.equal(typeof signer.sign, 'function') + assert.equal(typeof signer.verify, 'function') + + assert.equal(signer.signer, signer) + + const { verifier } = signer + assert.equal(typeof verifier.verify, 'function') + assert.equal(verifier.code, 0x1205) + assert.equal(verifier.signatureCode, 0xd01205) + assert.equal(verifier.signatureAlgorithm, 'RS256') + assert.equal(verifier.did(), signer.did()) + + const bytes = signer.toArchive() + if (!(bytes instanceof Uint8Array)) { + return assert.fail() + } + assert.deepEqual([0x1305, 2], varint.decode(bytes)) + + /** @type {CryptoKey} */ + // @ts-expect-error - field is private + const privateKey = signer.privateKey + assert.equal(privateKey.type, 'private') + assert.deepEqual(Object(privateKey.algorithm), { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { + name: 'SHA-256', + }, + }) + assert.equal(privateKey.extractable, true) + assert.deepEqual(privateKey.usages, ['sign']) + }) + + it('can archive 🔁 restore extractable', async () => { + const original = await RSA.generate({ extractable: true }) + const restored = RSA.from(original.toArchive()) + const payload = utf8.encode('hello world') + + assert.equal( + await restored.verify(payload, await original.sign(payload)), + true + ) + + assert.equal( + await original.verify(payload, await restored.sign(payload)), + true + ) + }) + + it('can sign & verify', async () => { + const signer = await RSA.generate() + const payload = utf8.encode('hello world') + + const signature = await signer.sign(payload) + assert.equal(signature.code, signer.signatureCode) + assert.equal(signature.algorithm, signer.signatureAlgorithm) + + const { verifier } = signer + assert.equal(await verifier.verify(payload, signature), true) + assert.equal(await signer.verify(payload, signature), true) + }) + + it('can parse verifier', async () => { + const principal = await RSA.generate() + const payload = utf8.encode('hello world') + const verifier = RSA.Verifier.parse(principal.did()) + + const signature = await principal.sign(payload) + assert.equal(await verifier.verify(payload, signature), true) + }) + + it('can format / parse verifier', async () => { + const { signer, verifier: original } = await RSA.generate() + + const did = await original.did() + const parsed = RSA.Verifier.parse(did) + const payload = utf8.encode('hello world') + + const signature = await signer.sign(payload) + assert.equal(await original.verify(payload, signature), true) + assert.equal(await parsed.verify(payload, signature), true) + assert.deepEqual(did, await parsed.did()) + }) + + it('can parse', async () => { + const did = + 'did:key:z4MXj1wBzi9jUstyPMS4jQqB6KdJaiatPkAtVtGc6bQEQEEsKTic4G7Rou3iBf9vPmT5dbkm9qsZsuVNjq8HCuW1w24nhBFGkRE4cd2Uf2tfrB3N7h4mnyPp1BF3ZttHTYv3DLUPi1zMdkULiow3M1GfXkoC6DoxDUm1jmN6GBj22SjVsr6dxezRVQc7aj9TxE7JLbMH1wh5X3kA58H3DFW8rnYMakFGbca5CB2Jf6CnGQZmL7o5uJAdTwXfy2iiiyPxXEGerMhHwhjTA1mKYobyk2CpeEcmvynADfNZ5MBvcCS7m3XkFCMNUYBS9NQ3fze6vMSUPsNa6GVYmKx2x6JrdEjCk3qRMMmyjnjCMfR4pXbRMZa3i' + + const verifier = RSA.Verifier.parse(did) + assert.deepEqual(verifier.did(), did) + + const payload = utf8.encode('hello world') + const signer = await RSA.generate({ extractable: true }) + const signature = await signer.sign(payload) + + assert.equal(await verifier.verify(payload, signature), false) + }) + + it('can not verify other signatures', async () => { + const signer = await RSA.generate() + const payload = utf8.encode('hello world') + const signature = await signer.sign(payload) + + assert.equal(await signer.verify(payload, signature), true) + + assert.equal( + await signer.verify(payload, { + ...signature, + // @ts-expect-error + code: signature.code + 1, + }), + false + ) + }) +}) + +/** + * @param {Exclude} format + * @param {RSA.RSASigner|RSA.RSAVerifier} principal + */ +const exportKey = async (format, principal) => { + // @ts-expect-error - accessing private keys + const cryptoKey = principal.privateKey || principal.publicKey + return webcrypto.subtle.exportKey(format, cryptoKey) +} + +describe('PrivateKey', () => { + it('PublicKey fromSPKI 🔁 toSPKI', async () => { + const { verifier } = await RSA.generate() + const spki = new Uint8Array(await exportKey('spki', verifier)) + const key = PublicKey.fromSPKI(spki) + assert.deepEqual(spki, PublicKey.toSPKI(key)) + }) + + it('PKCS8 decode 🔁 encode', async () => { + const { signer } = await RSA.generate({ extractable: true }) + const expected = new Uint8Array(await exportKey('pkcs8', signer)) + const key = PKCS8.decode(expected) + const actual = PKCS8.encode(key) + + assert.deepEqual(actual, expected) + }) + + it('PrivateKey decode 🔁 encode', async () => { + const { signer } = await RSA.generate({ extractable: true }) + const pkcs8 = new Uint8Array(await exportKey('pkcs8', signer)) + const source = PKCS8.decode(pkcs8) + + const key = PrivateKey.decode(source) + const bytes = PrivateKey.encode(key) + + assert.deepEqual(source, bytes) + }) + + it('PrivateKey fromPKCS8 🔁 toPKCS8', async () => { + const { signer } = await RSA.generate({ extractable: true }) + const pkcs8 = new Uint8Array(await exportKey('pkcs8', signer)) + const key = PrivateKey.fromPKCS8(pkcs8) + const info = PrivateKey.toPKCS8(key) + assert.deepEqual(info, pkcs8) + }) +}) + +it('multiformat', () => { + const value = multiformat.tagWith( + 5, + multiformat.tagWith(4, new Uint8Array([1, 1, 1])) + ) + + const outer = multiformat.untagWith(5, value) + assert.deepEqual( + { + buffer: value.buffer, + byteOffset: 1, + byteLength: value.byteLength - 1, + }, + { + buffer: outer.buffer, + byteOffset: outer.byteOffset, + byteLength: outer.byteLength, + } + ) + assert.deepEqual(outer, value.subarray(1)) + + const inner = multiformat.untagWith(4, value, 1) + assert.deepEqual( + { + buffer: value.buffer, + byteOffset: 2, + byteLength: value.byteLength - 2, + }, + { + buffer: inner.buffer, + byteOffset: inner.byteOffset, + byteLength: inner.byteLength, + } + ) + assert.deepEqual(inner, value.subarray(2)) + + assert.throws( + () => multiformat.untagWith(3, value), + /Expected multiformat with 0x3 tag instead got 0x5/ + ) +}) + +it('toJWK 🔁 fromJWK', async () => { + const { signer, verifier } = await RSA.generate({ extractable: true }) + + /** @type {CryptoKeyPair} */ + const keyPair = { + // @ts-expect-error - accessing private field + privateKey: signer.privateKey, + // @ts-expect-error - accessing private field + publicKey: verifier.publicKey, + } + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey) + + const privateKey = PrivateKey.decode( + multiformat.untagWith( + RSA.code, + /** @type {Uint8Array} */ (signer.toArchive()) + ) + ) + + assert.deepEqual(PrivateKey.fromJWK(jwk), privateKey) + assert.deepEqual(PrivateKey.toJWK(PrivateKey.fromJWK(jwk)), jwk) + + assert.deepEqual( + PublicKey.toJWK(privateKey), + await webcrypto.subtle.exportKey('jwk', keyPair.publicKey) + ) + + const publicKey = PublicKey.decode( + multiformat.untagWith( + signer.verifier.code, + // @ts-expect-error - accessing private property + signer.verifier.bytes + ) + ) + + assert.deepEqual(PublicKey.fromJWK(jwk), publicKey) + assert.deepEqual(PublicKey.fromJWK(PublicKey.toJWK(publicKey)), publicKey) + assert.deepEqual(PublicKey.fromJWK(PublicKey.toJWK(privateKey)), publicKey) +}) + +it('toSPKI 🔁 fromSPKI', async () => { + const signer = await RSA.generate({ extractable: true }) + const spki = new Uint8Array(await exportKey('spki', signer.verifier)) + + const privateKey = PrivateKey.decode( + multiformat.untagWith( + RSA.code, + /** @type {Uint8Array} */ (signer.toArchive()) + ) + ) + + assert.deepEqual(PrivateKey.toSPKI(privateKey), spki) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1ee589e..38161565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,7 @@ importers: mocha: ^10.0.0 multiformats: ^9.8.1 nyc: ^15.1.0 + one-webcrypto: ^1.0.3 playwright-test: ^8.1.1 typescript: ^4.8.3 dependencies: @@ -118,6 +119,7 @@ importers: '@noble/ed25519': 1.7.0 '@ucanto/interface': link:../interface multiformats: 9.8.1 + one-webcrypto: 1.0.3 devDependencies: '@types/chai': 4.3.3 '@types/mocha': 9.1.1 @@ -2348,6 +2350,10 @@ packages: wrappy: 1.0.2 dev: true + /one-webcrypto/1.0.3: + resolution: {integrity: sha512-fu9ywBVBPx0gS9K0etIROTiCkvI5S1TDjFsYFb3rC1ewFxeOqsbzq7aIMBHsYfrTHBcGXJaONXXjTl8B01cW1Q==} + dev: false + /onetime/5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'}