Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make ts-ucans compatible with old UCAN versions #42

Merged
merged 5 commits into from
Jan 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ jobs:
- name: Install Dependencies
run: yarn install --frozen-lockfile --network-concurrency 1

- name: Lint
run: yarn lint

- name: Build & Test
run: yarn test

# Run lint after testing for more info
- name: Lint
run: yarn lint
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@typescript-eslint/eslint-plugin": "^5.5.0",
"@typescript-eslint/parser": "^5.5.0",
"eslint": "^8.3.0",
"fast-check": "^2.20.0",
"jest": "^27.2.0",
"rimraf": "^3.0.2",
"ts-jest": "^27.0.5",
Expand Down
3 changes: 1 addition & 2 deletions src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,7 @@ export class Builder<State extends Partial<BuildableState>> {
}

function isProof(proof: Store | Chained): proof is Chained {
// @ts-ignore
const encodedFnc = proof.encoded
const encodedFnc = (proof as unknown as Record<string, unknown>).encoded
return typeof encodedFnc === "function"
}

Expand Down
92 changes: 92 additions & 0 deletions src/compatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// A module to hold all the ugly compatibility logic
// for getting from old UCANs to newer version UCANs.
import * as util from "./util"
import { UcanParts, isUcanHeader, isUcanPayload } from "./types"


type UcanHeader_0_0_1 = {
alg: string
typ: string
uav: string
}

type UcanPayload_0_0_1 = {
iss: string
aud: string
nbf?: number
exp: number
rsc: string
ptc: string
prf?: string
}

function isUcanHeader_0_0_1(obj: unknown): obj is UcanHeader_0_0_1 {
return util.isRecord(obj)
&& util.hasProp(obj, "alg") && typeof obj.alg === "string"
&& util.hasProp(obj, "typ") && typeof obj.typ === "string"
&& util.hasProp(obj, "uav") && typeof obj.uav === "string"
}

function isUcanPayload_0_0_1(obj: unknown): obj is UcanPayload_0_0_1 {
return util.isRecord(obj)
&& util.hasProp(obj, "iss") && typeof obj.iss === "string"
&& util.hasProp(obj, "aud") && typeof obj.aud === "string"
&& (!util.hasProp(obj, "nbf") || typeof obj.nbf === "number")
&& util.hasProp(obj, "exp") && typeof obj.exp === "number"
&& util.hasProp(obj, "rsc") && typeof obj.rsc === "string"
&& util.hasProp(obj, "ptc") && typeof obj.ptc === "string"
&& (!util.hasProp(obj, "prf") || typeof obj.prf === "string")
}


export function handleCompatibility(header: unknown, payload: unknown): UcanParts {
const fail = (place: string, reason: string) => new Error(`Can't parse UCAN ${place}: ${reason}`)

if (!util.isRecord(header)) throw fail("header", "Invalid format: Expected a record")

// parse either the "ucv" or "uav" as a version in the header
// we translate 'uav: 1.0.0' into 'ucv: 0.0.1'
// we only support versions 0.7.0 and 0.0.1
let version: "0.7.0" | "0.0.1" = "0.7.0"
if (!util.hasProp(header, "ucv") || typeof header.ucv !== "string") {
if (!util.hasProp(header, "uav") || typeof header.uav !== "string") {
throw fail("header", "Invalid format: Missing version indicator")
} else if (header.uav !== "1.0.0") {
throw fail("header", `Unsupported version 'uav: ${header.uav}'`)
}
version = "0.0.1"
} else if (header.ucv !== "0.7.0") {
throw fail("header", `Unsupported version 'ucv: ${header.ucv}'`)
}

if (version === "0.7.0") {
if (!isUcanHeader(header)) throw fail("header", "Invalid format")
if (!isUcanPayload(payload)) throw fail("payload", "Invalid format")
return { header, payload }
}

// we know it's version 0.0.1

if (!isUcanHeader_0_0_1(header)) throw fail("header", "Invalid version 0.0.1 format")
if (!isUcanPayload_0_0_1(payload)) throw fail("payload", "Invalid version 0.0.1 format")

return {
header: {
alg: header.alg,
typ: header.typ,
ucv: "0.0.1",
},
payload: {
iss: payload.iss,
aud: payload.aud,
nbf: payload.nbf,
exp: payload.exp,
att: [{
rsc: payload.rsc,
cap: payload.ptc,
}],
prf: payload.prf != null ? [payload.prf] : []
},
}
}

120 changes: 118 additions & 2 deletions src/crypto/rsa.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { webcrypto } from "one-webcrypto"
import * as uint8arrays from "uint8arrays"

export const RSA_ALG = "RSASSA-PKCS1-v1_5"
export const DEFAULT_KEY_SIZE = 2048
Expand All @@ -7,7 +8,7 @@ export const SALT_LEGNTH = 128

export const generateKeypair = async (size: number = DEFAULT_KEY_SIZE): Promise<CryptoKeyPair> => {
return await webcrypto.subtle.generateKey(
{
{
name: RSA_ALG,
modulusLength: size,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
Expand All @@ -27,7 +28,7 @@ export const importKey = async (key: Uint8Array): Promise<CryptoKey> => {
return await webcrypto.subtle.importKey(
"spki",
key.buffer,
{ name: RSA_ALG, hash: { name: DEFAULT_HASH_ALG }},
{ name: RSA_ALG, hash: { name: DEFAULT_HASH_ALG } },
true,
["verify"]
)
Expand All @@ -50,3 +51,118 @@ export const verify = async (msg: Uint8Array, sig: Uint8Array, pubKey: Uint8Arra
msg.buffer
)
}

/**
* 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
*/
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?!)
rsaPublicKey
])
return uint8arrays.concat([
ASN_SEQUENCE_TAG,
asn1DERLengthEncode(SPKI_PARAMS_ENCODED.length + bitStringEncoded.length),
SPKI_PARAMS_ENCODED,
bitStringEncoded,
])
}

export const convertSubjectPublicKeyInfoToRSAPublicKey = (subjectPublicKeyInfo: Uint8Array): Uint8Array => {
let position = 0
// go into the top-level SEQUENCE
position = asn1Into(subjectPublicKeyInfo, ASN_SEQUENCE_TAG, position).position
// skip the header we expect (SKPI_PARAMS_ENCODED)
position = asn1Skip(subjectPublicKeyInfo, ASN_SEQUENCE_TAG, position)
// 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]
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.`)
}
return bitstring.slice(1)
}

// ㊙️
// but some exposed for testing :/

export function asn1DERLengthEncode(length: number): Uint8Array {
if (length < 0 || !isFinite(length)) {
throw new TypeError(`Expected non-negative number. Got ${length}`)
}

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

const octets: number[] = []
while (length !== 0) {
octets.push(length & 0xFF)
length = length >>> 8
}
octets.reverse()
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 }
}

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]
}
return { number: length, consumed: numberBytes + 1 }
}

export function asn1DERLengthDecode(bytes: Uint8Array): number {
return asn1DERLengthDecodeWithConsumed(bytes).number
}

function asn1Skip(input: Uint8Array, expectedTag: Uint8Array, position: number): number {
const parsed = asn1Into(input, expectedTag, position)
return parsed.position + parsed.length
}

function asn1Into(input: Uint8Array, expectedTag: Uint8Array, position: number): { position: number; length: number } {
// tag
const lengthPos = position + expectedTag.length
const actualTag = input.subarray(position, lengthPos)
if (!uint8arrays.equals(actualTag, expectedTag)) {
throw new Error(`ASN parsing error: Expected tag 0x${uint8arrays.toString(expectedTag, "hex")} at position ${position}, but got ${uint8arrays.toString(actualTag, "hex")}.`)
}

// length
const length = asn1DERLengthDecodeWithConsumed(input.subarray(lengthPos/*, we don't know the end */))
const contentPos = position + 1 + length.consumed

// content
return { position: contentPos, length: length.number }
}
26 changes: 23 additions & 3 deletions src/did/prefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ import * as uint8arrays from "uint8arrays"
import { KeyType } from "../types"


// Each prefix is varint-encoded. So e.g. 0x1205 gets varint-encoded to 0x8524
// The varint encoding is described here: https://github.com/multiformats/unsigned-varint
// These varints are encoded big-endian in 7-bit pieces.
// So 0x1205 is split up into 0x12 and 0x05
// Because there's another byte to be read, the MSB of 0x05 is set: 0x85
// The next 7 bits encode as 0x24 (instead of 0x12) => 0x8524

/** https://github.com/multiformats/multicodec/blob/e9ecf587558964715054a0afcc01f7ace220952c/table.csv#L94 */
export const EDWARDS_DID_PREFIX = new Uint8Array([ 0xed, 0x01 ])
/** https://github.com/multiformats/multicodec/blob/e9ecf587558964715054a0afcc01f7ace220952c/table.csv#L91 */
export const BLS_DID_PREFIX = new Uint8Array([ 0xea, 0x01 ])
export const RSA_DID_PREFIX = new Uint8Array([ 0x00, 0xf5, 0x02 ])
export const BASE58_DID_PREFIX = "did:key:z"
/** https://github.com/multiformats/multicodec/blob/e9ecf587558964715054a0afcc01f7ace220952c/table.csv#L146 */
export const RSA_DID_PREFIX = new Uint8Array([ 0x85, 0x24 ])
/** Old RSA DID prefix, used pre-standardisation */
export const RSA_DID_PREFIX_OLD = new Uint8Array([ 0x00, 0xf5, 0x02 ])

export const BASE58_DID_PREFIX = "did:key:z" // z is the multibase prefix for base58btc byte encoding

/**
* Magic bytes.
Expand Down Expand Up @@ -34,6 +47,13 @@ export const parseMagicBytes = (prefixedKey: Uint8Array): {
type: "rsa"
}

// RSA OLD
} else if (hasPrefix(prefixedKey, RSA_DID_PREFIX_OLD)) {
return {
keyBytes: prefixedKey.slice(RSA_DID_PREFIX_OLD.byteLength),
type: "rsa"
}

// EDWARDS
} else if (hasPrefix(prefixedKey, EDWARDS_DID_PREFIX)) {
return {
Expand All @@ -56,5 +76,5 @@ export const parseMagicBytes = (prefixedKey: Uint8Array): {
* Determines if a Uint8Array has a given indeterminate length-prefix.
*/
export const hasPrefix = (prefixedKey: Uint8Array, prefix: Uint8Array): boolean => {
return uint8arrays.equals(prefix, prefixedKey.slice(0, prefix.byteLength))
return uint8arrays.equals(prefix, prefixedKey.subarray(0, prefix.byteLength))
}
31 changes: 26 additions & 5 deletions src/did/transformers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as uint8arrays from "uint8arrays"

import { BASE58_DID_PREFIX, magicBytes, parseMagicBytes } from "./prefix"
import * as rsa from "../crypto/rsa"
import { BASE58_DID_PREFIX, RSA_DID_PREFIX_OLD, magicBytes, parseMagicBytes, hasPrefix } from "./prefix"
import { KeyType, Encodings } from "../types"

/**
Expand All @@ -16,6 +17,14 @@ export function publicKeyBytesToDid(
throw new Error(`Key type '${type}' not supported`)
}

if (type === "rsa") {
// See also the comment in didToPublicKeyBytes
// In this library, we're assuming a single byte encoding for all types of keys.
// For RSA that is "SubjectPublicKeyInfo", because that's what the WebCrypto API understands.
// But DIDs assume that all public keys are encoded as "RSAPublicKey".
publicKeyBytes = rsa.convertSubjectPublicKeyInfoToRSAPublicKey(publicKeyBytes)
}

const prefixedBytes = uint8arrays.concat([prefix, publicKeyBytes])

// Encode prefixed
Expand All @@ -36,7 +45,9 @@ export function publicKeyToDid(


/**
* Convert a DID (did:key) to the public key in bytes
* Convert a DID (did:key) to the public key into bytes in SubjectPublicKeyInfo (spki) format.
*
* For consumption e.g. in the WebCrypto API.
*/
export function didToPublicKeyBytes(did: string): {
publicKey: Uint8Array
Expand All @@ -48,11 +59,21 @@ export function didToPublicKeyBytes(did: string): {

const didWithoutPrefix = did.slice(BASE58_DID_PREFIX.length)
const magicBytes = uint8arrays.fromString(didWithoutPrefix, "base58btc")
const { keyBytes, type } = parseMagicBytes(magicBytes)
const parsed = parseMagicBytes(magicBytes)

if (parsed.type === "rsa" && !hasPrefix(magicBytes, RSA_DID_PREFIX_OLD)) {
// DID RSA keys are ASN.1 DER encoded "RSAPublicKeys" (PKCS #1).
// But the WebCrypto API mostly works with "SubjectPublicKeyInfo" (SPKI),
// which wraps RSAPublicKey with some metadata.
// In an unofficial RSA multiformat we were using, we used SPKI,
// so we have to be careful not to transform *every* RSA DID to SPKI, but
// only newer DIDs.
parsed.keyBytes = rsa.convertRSAPublicKeyToSubjectPublicKeyInfo(parsed.keyBytes)
}

return {
publicKey: keyBytes,
type
publicKey: parsed.keyBytes,
type: parsed.type,
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/did/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ export async function verifySignature(data: Uint8Array, signature: Uint8Array, d
switch (type) {

case "ed25519":
return await nacl.sign.detached.verify(data, signature, publicKey)
return nacl.sign.detached.verify(data, signature, publicKey)

case "rsa":
return await rsa.verify(data, signature, publicKey)
case "rsa":
return await rsa.verify(data, signature, publicKey)

default: return false
}
Expand Down
Loading