Skip to content

Commit

Permalink
Add an exportable option to default-plugins (#109)
Browse files Browse the repository at this point in the history
Addresses issue #108

---

* Add an `exportable` option to EcdsaKeyPair
Addresses issue #108

* Implement and test import / export for ECDSA Keypair

* Import and Export for RSA keys

* Import / Export for EdKeypair

* [WIP] Remove unit8array as a dependency

* [bug] remove unused dependency

* [fix] Normalize back to uintarrays

[Fix] Normalize on uint8arrays

* [feat] Normalize `export` to use JWK types

* [fix] added notes about ed25519 export

* [fix] Revised comment about export to include other parameters

* [fix] Generify ExportableKey type to allow for PrivateKeyJwk return
types
  • Loading branch information
kshinn committed Mar 15, 2024
1 parent 4b1be87 commit bf35b41
Show file tree
Hide file tree
Showing 10 changed files with 280 additions and 68 deletions.
16 changes: 8 additions & 8 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,16 @@ export interface Didable {
did: () => string
}

export interface ExportableKey {
export: (format?: Encodings) => Promise<string>
export interface ExportableKey<T> {
export: () => Promise<T>
}

export interface Keypair {
jwtAlg: string
sign: (msg: Uint8Array) => Promise<Uint8Array>
}

export interface DidableKey extends Didable, Keypair {}
export interface DidableKey extends Didable, Keypair { }

// MISC

Expand All @@ -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<void>
getByAudience(audience: string): Ucan[]
findByAudience(audience: string, predicate: (ucan: Ucan) => boolean): Ucan | null
add(ucan: Ucan): Promise<void>
getByAudience(audience: string): Ucan[]
findByAudience(audience: string, predicate: (ucan: Ucan) => boolean): Ucan | null
findWithCapability(
audience: string,
requiredCapability: Capability,
requiredIssuer: string,
): Iterable<DelegationChain>
): Iterable<DelegationChain>
}

// BUILDER
Expand Down
38 changes: 35 additions & 3 deletions packages/default-plugins/src/ed25519/keypair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PrivateKeyJwk> {

public jwtAlg = "EdDSA"

Expand Down Expand Up @@ -45,13 +46,44 @@ export class EdKeypair implements DidableKey, ExportableKey {
return ed25519.sign(this.secretKey, msg)
}

async export(format: Encodings = "base64pad"): Promise<string> {
async export(): Promise<PrivateKeyJwk> {
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<EdKeypair> {
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 })
}
}


Expand Down
24 changes: 16 additions & 8 deletions packages/default-plugins/src/p256/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AvailableCryptoKeyPair> => {
export const generateKeypair = async (
exportable = false
): Promise<AvailableCryptoKeyPair> => {
return await webcrypto.subtle.generateKey(
{
name: ALG,
namedCurve: DEFAULT_CURVE,
},
false,
[ "sign", "verify" ]
exportable,
["sign", "verify"]
)
}

Expand All @@ -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,
Expand All @@ -44,11 +46,17 @@ export const importKeypairJwk = async (
namedCurve: DEFAULT_CURVE,
},
true,
[ "verify" ]
["verify"]
)
return { privateKey, publicKey }
}

export const exportPrivateKeyJwk = async (
keyPair: AvailableCryptoKeyPair
): Promise<PrivateKeyJwk> => {
return await webcrypto.subtle.exportKey("jwk", keyPair.privateKey) as PrivateKeyJwk
}

export const exportKey = async (key: CryptoKey): Promise<Uint8Array> => {
const buf = await webcrypto.subtle.exportKey("raw", key)
return new Uint8Array(buf)
Expand All @@ -62,7 +70,7 @@ export const importKey = async (
key,
{ name: ALG, namedCurve: DEFAULT_CURVE },
true,
[ "verify" ]
["verify"]
)
}

Expand Down
37 changes: 21 additions & 16 deletions packages/default-plugins/src/p256/keypair.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,7 +8,7 @@ import {
} from "../types.js"


export class EcdsaKeypair implements DidableKey, ExportableKey {
export class EcdsaKeypair implements DidableKey, ExportableKey<PrivateKeyJwk> {

public jwtAlg = "ES256"

Expand All @@ -32,7 +30,7 @@ export class EcdsaKeypair implements DidableKey, ExportableKey {
exportable?: boolean
}): Promise<EcdsaKeypair> {
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`)
Expand All @@ -47,12 +45,12 @@ export class EcdsaKeypair implements DidableKey, ExportableKey {
params?: {
exportable?: boolean
}): Promise<EcdsaKeypair> {
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)
Expand All @@ -66,15 +64,22 @@ export class EcdsaKeypair implements DidableKey, ExportableKey {
return await crypto.sign(msg, this.keypair.privateKey)
}

async export(format: Encodings = "base64pad"): Promise<string> {
async export(): Promise<PrivateKeyJwk> {
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<EcdsaKeypair> {
return EcdsaKeypair.importFromJwk(jwk, { exportable: true })
}
}

Expand Down
66 changes: 50 additions & 16 deletions packages/default-plugins/src/rsa/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,24 @@ 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
export const DEFAULT_HASH_ALG = "SHA-256"
export const SALT_LEGNTH = 128


export const generateKeypair = async (size: number = DEFAULT_KEY_SIZE): Promise<CryptoKeyPair> => {
export const generateKeypair = async (size: number = DEFAULT_KEY_SIZE, exportable: boolean = false): Promise<CryptoKeyPair> => {
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"]
)
}

Expand All @@ -27,14 +28,47 @@ export const exportKey = async (key: CryptoKey): Promise<Uint8Array> => {
return new Uint8Array(buf)
}

export const exportPrivateKeyJwk = async (keyPair: AvailableCryptoKeyPair): Promise<JsonWebKey> => {
return await webcrypto.subtle.exportKey("jwk", keyPair.privateKey)
}

export const importKey = async (key: Uint8Array): Promise<CryptoKey> => {
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<AvailableCryptoKeyPair> => {
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<Uint8Array> => {
Expand Down Expand Up @@ -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([
Expand All @@ -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.`)
}
Expand All @@ -145,7 +179,7 @@ export function asn1DERLengthEncode(length: number): Uint8Array {
}

if (length <= 127) {
return new Uint8Array([ length ])
return new Uint8Array([length])
}

const octets: number[] = []
Expand All @@ -154,23 +188,23 @@ 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}`)
}

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 }
}
Expand Down
Loading

0 comments on commit bf35b41

Please sign in to comment.