diff --git a/README.md b/README.md index e98bc56..2e0b37f 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ KeyPair.fromPem(string); // => KeyPair Managing identities. ```ts -identity = Identity.fromPublicKey(key); // => Identity -identity = Identity.fromString(string); // => Identity -anonymous = new Identity(); // => Anonymous Identity +identity = Address.fromPublicKey(key); // => Address +identity = Address.fromString(string); // => Address +anonymous = new Address(); // => Anonymous Address identity.toString(keys); // => "mw7aekyjtsx2hmeadrua5cpitgy7pykjkok3gyth3ggsio4zwa" identity.toHex(keys); // => "01e736fc9624ff8ca7956189b6c1b66f55f533ed362ca48c884cd20065"; diff --git a/package-lock.json b/package-lock.json index ba3a4a2..55c0a88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "crc": "^3.8.0", "jest": "^27.4.5", "js-sha3": "^0.8.0", + "js-sha512": "^0.8.0", "node-forge": "^0.10.0", "ts-jest": "^27.1.1", "typescript": "^4.4.4", @@ -3423,6 +3424,11 @@ "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" }, + "node_modules/js-sha512": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.8.0.tgz", + "integrity": "sha512-PWsmefG6Jkodqt+ePTvBZCSMFgN7Clckjd0O7su3I0+BW2QWUTJNzjktHsztGLhncP2h8mcF9V9Y2Ha59pAViQ==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7451,6 +7457,11 @@ "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" }, + "js-sha512": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.8.0.tgz", + "integrity": "sha512-PWsmefG6Jkodqt+ePTvBZCSMFgN7Clckjd0O7su3I0+BW2QWUTJNzjktHsztGLhncP2h8mcF9V9Y2Ha59pAViQ==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 02051b1..0413e5a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "crc": "^3.8.0", "jest": "^27.4.5", "js-sha3": "^0.8.0", + "js-sha512": "^0.8.0", "node-forge": "^0.10.0", "ts-jest": "^27.1.1", "typescript": "^4.4.4", @@ -25,6 +26,7 @@ "scripts": { "test": "jest", "build": "tsc", + "watch": "npm run clean && npm run build -- -w", "clean": "rm -rf ./dist", "postinstall": "tsc" }, @@ -46,6 +48,7 @@ "jest": { "preset": "ts-jest", "testEnvironment": "node", + "maxWorkers": 1, "testMatch": [ "**/?(*.)+(spec|test).[jt]s?(x)" ], diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..54e4db2 --- /dev/null +++ b/src/const.ts @@ -0,0 +1,2 @@ +export const ONE_SECOND = 1000 +export const ONE_MINUTE = 60000 diff --git a/src/identity/address.ts b/src/identity/address.ts new file mode 100644 index 0000000..3b74a8c --- /dev/null +++ b/src/identity/address.ts @@ -0,0 +1,81 @@ +import base32Decode from "base32-decode" +import base32Encode from "base32-encode" +import crc from "crc" + +export const ANON_IDENTITY = "maa" +export class Address { + bytes: Uint8Array + + constructor(bytes?: Buffer) { + this.bytes = new Uint8Array(bytes ? bytes : [0x00]) + } + + static anonymous(): Address { + return new Address() + } + + static fromHex(hex: string): Address { + return new Address(Buffer.from(hex, "hex")) + } + + static fromString(string: string): Address { + if (string === ANON_IDENTITY) { + return new Address() + } + const base32Address = string.slice(1, -2).toUpperCase() + const base32Checksum = string.slice(-2).toUpperCase() + const identity = base32Decode(base32Address, "RFC4648") + const checksum = base32Decode(base32Checksum, "RFC4648") + + const check = Buffer.allocUnsafe(3) + check.writeUInt16BE(crc.crc16(Buffer.from(identity)), 0) + + if (Buffer.compare(Buffer.from(checksum), check.slice(0, 1)) !== 0) { + throw new Error("Invalid Checksum") + } + + return new Address(Buffer.from(identity)) + } + + isAnonymous(): boolean { + return Buffer.compare(this.toBuffer(), Buffer.from([0x00])) === 0 + } + + toBuffer(): Buffer { + return Buffer.from(this.bytes) + } + + toString(): string { + if (this.isAnonymous()) { + return ANON_IDENTITY + } + const identity = this.toBuffer() + const checksum = Buffer.allocUnsafe(3) + checksum.writeUInt16BE(crc.crc16(identity), 0) + + const leader = "m" + const base32Address = base32Encode(identity, "RFC4648", { + padding: false, + }) + const base32Checksum = base32Encode(checksum, "RFC4648").slice(0, 2) + return (leader + base32Address + base32Checksum).toLowerCase() + } + + toHex(): string { + if (this.isAnonymous()) { + return "00" + } + return Buffer.from(this.bytes).toString("hex") + } + + withSubresource(id: number): Address { + let bytes = Buffer.from(this.bytes.slice(0, 29)) + bytes[0] = 0x80 + ((id & 0x7f000000) >> 24) + const subresourceBytes = Buffer.from([ + (id & 0x00ff0000) >> 16, + (id & 0x0000ff00) >> 8, + id & 0x000000ff, + ]) + return new Address(Buffer.concat([bytes, subresourceBytes])) + } +} diff --git a/src/identity/anonymous/anonymous-identity.ts b/src/identity/anonymous/anonymous-identity.ts new file mode 100644 index 0000000..d49208d --- /dev/null +++ b/src/identity/anonymous/anonymous-identity.ts @@ -0,0 +1,20 @@ +import { Identity } from "../types" +import { EMPTY } from "../../message/cose" +import { Address } from "../address" + +export class AnonymousIdentity extends Identity { + async sign() { + return EMPTY + } + async verify() { + return false + } + + async getAddress(): Promise
{ + return Address.anonymous() + } + + toJSON(): { dataType: string } { + return { dataType: this.constructor.name } + } +} diff --git a/src/identity/anonymous/index.ts b/src/identity/anonymous/index.ts new file mode 100644 index 0000000..f9451d9 --- /dev/null +++ b/src/identity/anonymous/index.ts @@ -0,0 +1 @@ +export { AnonymousIdentity } from "./anonymous-identity" diff --git a/src/identity/ed25519/__tests__/ed25519-identity.test.ts b/src/identity/ed25519/__tests__/ed25519-identity.test.ts new file mode 100644 index 0000000..da6f2e0 --- /dev/null +++ b/src/identity/ed25519/__tests__/ed25519-identity.test.ts @@ -0,0 +1,47 @@ +import { Ed25519KeyPairIdentity } from "../ed25519-key-pair-identity" + +describe("keys", () => { + test("getSeedWords", () => { + const seedWords = Ed25519KeyPairIdentity.getMnemonic() + + expect(seedWords.split(" ")).toHaveLength(12) + }) + + test("fromSeedWords", async function () { + const seedWords = Ed25519KeyPairIdentity.getMnemonic() + const badWords = "abandon abandon abandon" + + const alice = Ed25519KeyPairIdentity.fromMnemonic(seedWords) + const bob = Ed25519KeyPairIdentity.fromMnemonic(seedWords) + + const aliceAddress = (await alice.getAddress()).toString() + const bobAddress = (await bob.getAddress()).toString() + + expect(aliceAddress).toStrictEqual(bobAddress) + expect(() => { + Ed25519KeyPairIdentity.fromMnemonic(badWords) + }).toThrow() + }) + + test("fromPem", async function () { + const pem = ` + -----BEGIN PRIVATE KEY----- + MC4CAQAwBQYDK2VwBCIEICT3i6WfLx4t3UF6R8aEfczyATc/jvqvOrNga2MJfA2R + -----END PRIVATE KEY-----` + const badPem = ` + -----BEGIN PRIVATE CAT----- + MEOW + -----END PRIVATE CAT-----` + + const alice = Ed25519KeyPairIdentity.fromPem(pem) + const bob = Ed25519KeyPairIdentity.fromPem(pem) + + const aliceAddress = (await alice.getAddress()).toString() + const bobAddress = (await bob.getAddress()).toString() + + expect(aliceAddress).toStrictEqual(bobAddress) + expect(() => { + Ed25519KeyPairIdentity.fromPem(badPem) + }).toThrow() + }) +}) diff --git a/src/identity/ed25519/ed25519-key-pair-identity.ts b/src/identity/ed25519/ed25519-key-pair-identity.ts new file mode 100644 index 0000000..b159ee9 --- /dev/null +++ b/src/identity/ed25519/ed25519-key-pair-identity.ts @@ -0,0 +1,80 @@ +import forge, { pki } from "node-forge" +import * as bip39 from "bip39" +import { CoseKey } from "../../message/cose" +const ed25519 = pki.ed25519 +import { PublicKeyIdentity } from "../types" +import { Address } from "../address" + +export class Ed25519KeyPairIdentity extends PublicKeyIdentity { + publicKey: ArrayBuffer + protected privateKey: ArrayBuffer + + protected constructor(publicKey: ArrayBuffer, privateKey: ArrayBuffer) { + super() + this.publicKey = publicKey + this.privateKey = privateKey + } + + static getMnemonic(): string { + return bip39.generateMnemonic() + } + + async getAddress(): Promise
{ + const coseKey = this.getCoseKey() + return coseKey.toAddress() + } + + static fromMnemonic(mnemonic: string): Ed25519KeyPairIdentity { + const sanitized = mnemonic.trim().split(/\s+/g).join(" ") + if (!bip39.validateMnemonic(sanitized)) { + throw new Error("Invalid Mnemonic") + } + const seed = bip39.mnemonicToSeedSync(sanitized).slice(0, 32) + const keys = ed25519.generateKeyPair({ seed }) + return new Ed25519KeyPairIdentity(keys.publicKey, keys.privateKey) + } + + static fromPem(pem: string): Ed25519KeyPairIdentity { + try { + const der = forge.pem.decode(pem)[0].body + const asn1 = forge.asn1.fromDer(der.toString()) + const { privateKeyBytes } = ed25519.privateKeyFromAsn1(asn1) + const keys = ed25519.generateKeyPair({ seed: privateKeyBytes }) + return new Ed25519KeyPairIdentity(keys.publicKey, keys.privateKey) + } catch (e) { + throw new Error("Invalid PEM") + } + } + + async sign(data: ArrayBuffer): Promise { + return ed25519.sign({ + message: data as Uint8Array, + privateKey: this.privateKey as Uint8Array, + }) + } + async verify(m: ArrayBuffer): Promise { + throw new Error("Method not implemented.") + } + + getCoseKey(): CoseKey { + const c = new Map() + c.set(1, 1) // kty: OKP + c.set(3, -8) // alg: EdDSA + c.set(-1, 6) // crv: Ed25519 + c.set(4, [2]) // key_ops: [verify] + c.set(-2, this.publicKey) // x: publicKey + return new CoseKey(c) + } + + toJSON(): { + dataType: string + publicKey: ArrayBuffer + privateKey: ArrayBuffer + } { + return { + dataType: this.constructor.name, + publicKey: this.publicKey, + privateKey: this.privateKey, + } + } +} diff --git a/src/identity/ed25519/index.ts b/src/identity/ed25519/index.ts new file mode 100644 index 0000000..9c6ff32 --- /dev/null +++ b/src/identity/ed25519/index.ts @@ -0,0 +1 @@ +export * from "./ed25519-key-pair-identity" diff --git a/src/identity/identity.test.ts b/src/identity/identity.test.ts index 6e8ed05..2196b24 100644 --- a/src/identity/identity.test.ts +++ b/src/identity/identity.test.ts @@ -1,4 +1,4 @@ -import { Identity } from "../identity"; +import { Address } from "../identity" describe("identity", () => { function id(seed: number) { @@ -10,67 +10,67 @@ describe("identity", () => { 0, 0, 0, 0, 0, 0, 0, 0, seed >> 24, seed >> 16, seed >> 8, seed & 0xff, ]); - return new Identity(Buffer.from(bytes)); + return new Address(Buffer.from(bytes)) } test("can read anonymous", () => { - const anon = new Identity(); - const anonStr = anon.toString(); + const anon = new Address() + const anonStr = anon.toString() - expect(anon.isAnonymous()).toBe(true); - expect(anon).toStrictEqual(Identity.fromString(anonStr)); - }); + expect(anon.isAnonymous()).toBe(true) + expect(anon).toStrictEqual(Address.fromString(anonStr)) + }) test("byte array conversion", () => { - const anon = new Identity(); - const alice = id(1); - const bob = id(2); + const anon = new Address() + const alice = id(1) + const bob = id(2) - expect(anon.toString()).not.toStrictEqual(alice.toString()); - expect(alice.toString()).not.toStrictEqual(bob.toString()); + expect(anon.toString()).not.toStrictEqual(alice.toString()) + expect(alice.toString()).not.toStrictEqual(bob.toString()) - expect(anon.toBuffer()).not.toStrictEqual(alice.toBuffer()); - expect(alice.toBuffer()).not.toStrictEqual(bob.toBuffer()); + expect(anon.toBuffer()).not.toStrictEqual(alice.toBuffer()) + expect(alice.toBuffer()).not.toStrictEqual(bob.toBuffer()) - expect(Identity.fromString(anon.toString())).toStrictEqual(anon); - expect(Identity.fromString(alice.toString())).toStrictEqual(alice); - expect(Identity.fromString(bob.toString())).toStrictEqual(bob); - }); + expect(Address.fromString(anon.toString())).toStrictEqual(anon) + expect(Address.fromString(alice.toString())).toStrictEqual(alice) + expect(Address.fromString(bob.toString())).toStrictEqual(bob) + }) test("textual format 1", () => { - const alice = Identity.fromString( - "mahek5lid7ek7ckhq7j77nfwgk3vkspnyppm2u467ne5mwiqys" - ); - const bob = Identity.fromHex( - "01c8aead03f915f128f0fa7ff696c656eaa93db87bd9aa73df693acb22" - ); + const alice = Address.fromString( + "mahek5lid7ek7ckhq7j77nfwgk3vkspnyppm2u467ne5mwiqys", + ) + const bob = Address.fromHex( + "01c8aead03f915f128f0fa7ff696c656eaa93db87bd9aa73df693acb22", + ) - expect(alice).toStrictEqual(bob); - }); + expect(alice).toStrictEqual(bob) + }) test("textual format 2", () => { - const alice = Identity.fromString( - "mqbfbahksdwaqeenayy2gxke32hgb7aq4ao4wt745lsfs6wiaaaaqnz" - ); - const bob = Identity.fromHex( - "804a101d521d810211a0c6346ba89bd1cc1f821c03b969ff9d5c8b2f59000001" - ); + const alice = Address.fromString( + "mqbfbahksdwaqeenayy2gxke32hgb7aq4ao4wt745lsfs6wiaaaaqnz", + ) + const bob = Address.fromHex( + "804a101d521d810211a0c6346ba89bd1cc1f821c03b969ff9d5c8b2f59000001", + ) - expect(alice).toStrictEqual(bob); - }); + expect(alice).toStrictEqual(bob) + }) test("subresource 1", () => { - const alice = Identity.fromString( - "mahek5lid7ek7ckhq7j77nfwgk3vkspnyppm2u467ne5mwiqys" - ).withSubresource(1); - const bob = Identity.fromHex( - "80c8aead03f915f128f0fa7ff696c656eaa93db87bd9aa73df693acb22000001" - ); - const charlie = Identity.fromHex( - "80c8aead03f915f128f0fa7ff696c656eaa93db87bd9aa73df693acb22000002" - ); + const alice = Address.fromString( + "mahek5lid7ek7ckhq7j77nfwgk3vkspnyppm2u467ne5mwiqys", + ).withSubresource(1) + const bob = Address.fromHex( + "80c8aead03f915f128f0fa7ff696c656eaa93db87bd9aa73df693acb22000001", + ) + const charlie = Address.fromHex( + "80c8aead03f915f128f0fa7ff696c656eaa93db87bd9aa73df693acb22000002", + ) - expect(alice).toStrictEqual(bob); - expect(bob.withSubresource(2)).toStrictEqual(charlie); - }); -}); + expect(alice).toStrictEqual(bob) + expect(bob.withSubresource(2)).toStrictEqual(charlie) + }) +}) diff --git a/src/identity/index.ts b/src/identity/index.ts index 787632b..ffeb473 100644 --- a/src/identity/index.ts +++ b/src/identity/index.ts @@ -1,89 +1,5 @@ -import base32Decode from "base32-decode"; -import base32Encode from "base32-encode"; -import crc from "crc"; - -import { Key } from "../keys"; -import { CoseKey } from "../message/cose"; - -export const ANON_IDENTITY = "maa" -export class Identity { - bytes: Uint8Array; - - constructor(bytes?: Buffer) { - this.bytes = new Uint8Array(bytes ? bytes : [0x00]); - } - - static anonymous(): Identity { - return new Identity(); - } - - static fromHex(hex: string): Identity { - return new Identity(Buffer.from(hex, "hex")); - } - - static fromPublicKey(key: Key): Identity { - const coseKey = new CoseKey(key); - return coseKey.toIdentity(); - } - - static fromString(string: string): Identity { - if (string === ANON_IDENTITY) { - return new Identity() - } - const base32Identity = string.slice(1, -2).toUpperCase(); - const base32Checksum = string.slice(-2).toUpperCase(); - const identity = base32Decode(base32Identity, "RFC4648"); - const checksum = base32Decode(base32Checksum, "RFC4648"); - - const check = Buffer.allocUnsafe(3); - check.writeUInt16BE(crc.crc16(Buffer.from(identity)), 0); - - if (Buffer.compare(Buffer.from(checksum), check.slice(0, 1)) !== 0) { - throw new Error("Invalid Checksum"); - } - - return new Identity(Buffer.from(identity)); - } - - isAnonymous(): boolean { - return Buffer.compare(this.toBuffer(), Buffer.from([0x00])) === 0; - } - - toBuffer(): Buffer { - return Buffer.from(this.bytes); - } - - toString(): string { - if (this.isAnonymous()) { - return ANON_IDENTITY - } - const identity = this.toBuffer(); - const checksum = Buffer.allocUnsafe(3); - checksum.writeUInt16BE(crc.crc16(identity), 0); - - const leader = "m"; - const base32Identity = base32Encode(identity, "RFC4648", { - padding: false, - }); - const base32Checksum = base32Encode(checksum, "RFC4648").slice(0, 2); - return (leader + base32Identity + base32Checksum).toLowerCase(); - } - - toHex(): string { - if (this.isAnonymous()) { - return "00"; - } - return Buffer.from(this.bytes).toString("hex"); - } - - withSubresource(id: number): Identity { - let bytes = Buffer.from(this.bytes.slice(0, 29)); - bytes[0] = 0x80 + ((id & 0x7f000000) >> 24); - const subresourceBytes = Buffer.from([ - (id & 0x00ff0000) >> 16, - (id & 0x0000ff00) >> 8, - id & 0x000000ff, - ]); - return new Identity(Buffer.concat([bytes, subresourceBytes])); - } -} +export * from "./address" +export * from "./webauthn" +export * from "./anonymous" +export * from "./ed25519" +export * from "./types" diff --git a/src/identity/types.ts b/src/identity/types.ts new file mode 100644 index 0000000..b52b953 --- /dev/null +++ b/src/identity/types.ts @@ -0,0 +1,34 @@ +import { CoseKey } from "../message/cose" +import { Address } from "./address" + +export interface Signer { + sign( + data: ArrayBuffer, + unprotectedHeader?: Map, + ): Promise +} + +export interface Verifier { + verify(data: ArrayBuffer): Promise +} + +export abstract class Identity implements Signer, Verifier { + abstract getAddress(): Promise
+ abstract toJSON(): unknown + abstract sign( + data: ArrayBuffer, + unprotectedHeader: Map, + ): Promise + abstract verify(data: ArrayBuffer): Promise + async getUnprotectedHeader( + cborMessageContent: ArrayBuffer, + cborProtectedHeader: ArrayBuffer, + ): Promise> { + return new Map() + } +} + +export abstract class PublicKeyIdentity extends Identity { + abstract publicKey: ArrayBuffer + abstract getCoseKey(): CoseKey +} diff --git a/src/identity/webauthn/__tests__/webauthn-identity.test.ts b/src/identity/webauthn/__tests__/webauthn-identity.test.ts new file mode 100644 index 0000000..d684d45 --- /dev/null +++ b/src/identity/webauthn/__tests__/webauthn-identity.test.ts @@ -0,0 +1,70 @@ +import cbor from "cbor" +import { tag } from "../../../message/cbor" +import { CoseKey } from "../../../message/cose" +import * as WebAuthnIdentityModule from "../webauthn-identity" + +const { WebAuthnIdentity } = WebAuthnIdentityModule + +describe("WebAuthnIdentity", () => { + it("should have static methods", () => { + expect(typeof WebAuthnIdentity.create).toBe("function") + expect(typeof WebAuthnIdentity.getCredential).toBe("function") + }) + it("getCoseKey()", () => { + const publicKeyMap = new Map([ + [1, 1], + [3, -8], + [-1, 6], + [-2, new Uint8Array(32)], + ]) + const identity = setup(publicKeyMap) + const coseKey = identity.getCoseKey() + expect(coseKey instanceof CoseKey).toBe(true) + }) + it("getUnprotectedHeader()", async () => { + const getCredentialMock = jest + .spyOn(WebAuthnIdentity, "getCredential") + .mockImplementationOnce(async () => { + return { + rawId: new ArrayBuffer(32), + response: { + authenticatorData: "mockAuthData", + clientDataJSON: Buffer.from("clientDataJSON"), + signature: "mockSignature", + } as AuthenticatorResponse, + } as PublicKeyCredential + }) + const cborMessageContent = cbor.encode( + tag( + 10001, + new Map([ + [1, "maexhjte7fss6cqg4hznyyqnn65pxdqrqbvjz6ocf7tl57zawf"], + [3, "ledger.info"], + ]), + ), + ) + const cborProtectedHeader = cbor.encode( + new Map([ + [1, "alg"], + [4, "kid"], + ]), + ) + const identity = setup() + const unprotectedHeader = await identity.getUnprotectedHeader( + cborMessageContent, + cborProtectedHeader, + ) + expect(getCredentialMock).toHaveBeenCalledTimes(1) + expect(unprotectedHeader.get("webauthn")).toBe(true) + expect(unprotectedHeader.get("authData")).toBe("mockAuthData") + expect(unprotectedHeader.get("clientData")).toBe("clientDataJSON") + expect(unprotectedHeader.get("signature")).toBe("mockSignature") + }) +}) + +function setup(publicKeyMap?: Map) { + const publicKey = Buffer.from(new ArrayBuffer(32)) + const cosePublicKey = cbor.encode(publicKeyMap ?? new Map([[-2, publicKey]])) + const rawId = new ArrayBuffer(32) + return new WebAuthnIdentity(cosePublicKey, rawId) +} diff --git a/src/identity/webauthn/index.ts b/src/identity/webauthn/index.ts new file mode 100644 index 0000000..4f3cf1f --- /dev/null +++ b/src/identity/webauthn/index.ts @@ -0,0 +1 @@ +export * from "./webauthn-identity" diff --git a/src/identity/webauthn/webauthn-identity.ts b/src/identity/webauthn/webauthn-identity.ts new file mode 100644 index 0000000..8b84627 --- /dev/null +++ b/src/identity/webauthn/webauthn-identity.ts @@ -0,0 +1,160 @@ +import cbor from "cbor" +import { ONE_MINUTE } from "../../const" +import { CoseKey, EMPTY } from "../../message/cose" +import { Address } from "../address" +import { PublicKeyIdentity } from "../types" +const sha512 = require("js-sha512") + +const CHALLENGE_BUFFER = new TextEncoder().encode("lifted") + +export class WebAuthnIdentity extends PublicKeyIdentity { + publicKey: ArrayBuffer + rawId: ArrayBuffer + cosePublicKey: ArrayBuffer + + constructor(cosePublicKey: ArrayBuffer, rawId: ArrayBuffer) { + super() + this.cosePublicKey = cosePublicKey + this.publicKey = WebAuthnIdentity.getPublicKeyFromCoseKey(cosePublicKey) + this.rawId = rawId + } + + async getAddress(): Promise
{ + return this.getCoseKey().toAddress() + } + + static getPublicKeyFromCoseKey(cosePublicKey: ArrayBuffer): ArrayBuffer { + const decoded = cbor.decodeFirstSync(cosePublicKey) + return decoded.get(-2) + } + + static async create(): Promise { + const publicKeyCredential = await createPublicKeyCredential() + const attestationResponse = publicKeyCredential?.response + if (!(attestationResponse instanceof AuthenticatorAttestationResponse)) { + throw new Error("Must be AuthenticatorAttestationResponse") + } + const attestationObj = cbor.decodeFirstSync( + attestationResponse.attestationObject, + ) + const cosePublicKey = getCosePublicKey(attestationObj.authData) + return new WebAuthnIdentity(cosePublicKey, publicKeyCredential.rawId) + } + + async sign(): Promise { + // This value ends up in the signature COSE field, but we don't + // use it for WebAuthn verification, so we simply set it to + // EMPTY. + return EMPTY + } + + async verify(_: ArrayBuffer): Promise { + throw new Error("Method not implemented.") + } + + static async getCredential( + credentialId: ArrayBuffer, + challenge?: Uint8Array | ArrayBuffer, + ): Promise { + let credential = (await window.navigator.credentials.get({ + publicKey: { + challenge: challenge ?? CHALLENGE_BUFFER, + timeout: ONE_MINUTE, + userVerification: "discouraged", + allowCredentials: [ + { + transports: ["nfc", "usb", "ble"], + id: credentialId, + type: "public-key", + }, + ], + }, + })) as PublicKeyCredential + return credential + } + + async getUnprotectedHeader( + cborMessageContent: ArrayBuffer, + cborProtectedHeader: ArrayBuffer, + ): Promise> { + const c = new Map() + c.set(0, cborProtectedHeader) + c.set( + 1, + Buffer.from(sha512.arrayBuffer(cborMessageContent)).toString("base64"), + ) + const challenge = cbor.encode(c) + const cred = await WebAuthnIdentity.getCredential(this.rawId, challenge) + const response = cred.response as AuthenticatorAssertionResponse + const m = new Map() + m.set("webauthn", true) + m.set("authData", response.authenticatorData) + m.set("clientData", Buffer.from(response.clientDataJSON).toString()) + m.set("signature", response.signature) + return m + } + + getCoseKey(): CoseKey { + let decoded = cbor.decode(this.cosePublicKey) + decoded.set(4, [2]) // key_ops: [verify] + return new CoseKey(decoded) + } + + toJSON(): { dataType: string; rawId: string; cosePublicKey: ArrayBuffer } { + return { + dataType: this.constructor.name, + rawId: Buffer.from(this.rawId).toString("base64"), + cosePublicKey: this.cosePublicKey, + } + } +} + +export async function createPublicKeyCredential(challenge = CHALLENGE_BUFFER) { + const publicKey: PublicKeyCredentialCreationOptions = { + challenge, + + rp: { + name: "lifted", + }, + + user: { + id: window.crypto.getRandomValues(new Uint8Array(32)), + name: "Lifted", + displayName: "Lifted", + }, + + attestation: "direct", + + authenticatorSelection: { + authenticatorAttachment: "cross-platform", + userVerification: "discouraged", + }, + + pubKeyCredParams: [ + /** + * we only use this algo because the major browsers (chrome, firefox, safari, brave, edge) support this. + * for example, if we create a credential with eddsa algo on chrome, we wouldnt be able to use it from firefox + * because it doesn't support this algo. + */ + { + // ES256 -7 ECDSA w/ SHA-256 + type: "public-key", + alg: -7, + }, + ], + } + + return (await navigator.credentials.create({ + publicKey, + })) as PublicKeyCredential +} + +function getCosePublicKey(authData: ArrayBuffer): ArrayBuffer { + const dataView = new DataView(new ArrayBuffer(2)) + const idLenBytes = authData.slice(53, 55) + // @ts-ignore + idLenBytes.forEach((value, index) => dataView.setUint8(index, value)) + const credentialIdLength = dataView.getUint16(0) + const cosePublicKey = authData.slice(55 + credentialIdLength) + return cosePublicKey +} diff --git a/src/index.ts b/src/index.ts index 3c52761..ee9382e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -export * from "./identity"; -export * from "./keys"; -export * from "./message"; -export * from "./network"; \ No newline at end of file +export * from "./identity" +export * from "./message" +export * from "./network" diff --git a/src/keys/index.ts b/src/keys/index.ts deleted file mode 100644 index 0b61926..0000000 --- a/src/keys/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import forge from "node-forge"; -import * as bip39 from "bip39"; - -const ed25519 = forge.pki.ed25519; - -export type Key = Uint8Array; - -interface KeyPairParams { - privateKey: Key; - publicKey: Key; -} - -export class KeyPair { - privateKey: Key; - publicKey: Key; - - constructor(keys: KeyPairParams) { - this.privateKey = keys.privateKey; - this.publicKey = keys.publicKey; - } - - static getMnemonic(): string { - return bip39.generateMnemonic(); - } - - static fromMnemonic(mnemonic: string): KeyPair { - const sanitized = mnemonic.trim().split(/\s+/g).join(" "); - if (!bip39.validateMnemonic(sanitized)) { - throw new Error("Invalid Mnemonic"); - } - const seed = bip39.mnemonicToSeedSync(sanitized).slice(0, 32); - const keys = ed25519.generateKeyPair({ seed }); - return new KeyPair(keys); - } - - static fromPem(pem: string): KeyPair { - try { - const der = forge.pem.decode(pem)[0].body; - const asn1 = forge.asn1.fromDer(der.toString()); - const { privateKeyBytes } = ed25519.privateKeyFromAsn1(asn1); - const keys = ed25519.generateKeyPair({ seed: privateKeyBytes }); - return keys; - } catch (e) { - throw new Error("Invalid PEM"); - } - } -} diff --git a/src/keys/keys.test.ts b/src/keys/keys.test.ts deleted file mode 100644 index d40aff9..0000000 --- a/src/keys/keys.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { KeyPair } from "../keys"; - -describe("keys", () => { - test("getSeedWords", () => { - const seedWords = KeyPair.getMnemonic(); - - expect(seedWords.split(" ")).toHaveLength(12); - }); - - test("fromSeedWords", () => { - const seedWords = KeyPair.getMnemonic(); - const badWords = "abandon abandon abandon"; - - const alice = KeyPair.fromMnemonic(seedWords); - const bob = KeyPair.fromMnemonic(seedWords); - - expect(alice.privateKey).toStrictEqual(bob.privateKey); - expect(() => { - KeyPair.fromMnemonic(badWords); - }).toThrow(); - }); - - test("fromPem", () => { - const pem = ` - -----BEGIN PRIVATE KEY----- - MC4CAQAwBQYDK2VwBCIEICT3i6WfLx4t3UF6R8aEfczyATc/jvqvOrNga2MJfA2R - -----END PRIVATE KEY-----`; - const badPem = ` - -----BEGIN PRIVATE CAT----- - MEOW - -----END PRIVATE CAT-----`; - - const alice = KeyPair.fromPem(pem); - const bob = KeyPair.fromPem(pem); - - expect(alice.privateKey).toStrictEqual(bob.privateKey); - expect(() => { - KeyPair.fromPem(badPem); - }).toThrow(); - }); -}); diff --git a/src/message/attributes/async-attribute/async-attribute.ts b/src/message/attributes/async-attribute/async-attribute.ts new file mode 100644 index 0000000..3cd9ec7 --- /dev/null +++ b/src/message/attributes/async-attribute/async-attribute.ts @@ -0,0 +1,29 @@ +import { Attributes } from "../attributes" +import { ResponseAttributeTypes } from "../types" + +type AsyncToken = ArrayBuffer +type AsyncResponseAttribute = [number, AsyncToken] +export class AsyncAttribute { + attribute: AsyncResponseAttribute | undefined = undefined + constructor(attr: AsyncResponseAttribute) { + this.attribute = attr + } + getAttribute() { + return this.attribute + } + getToken() { + return this.getAttribute()?.[1] + } + static getFromAttributes(attrs: Attributes): AsyncAttribute | undefined { + const attr = attrs.getAttributes().find(attr => { + if ( + Array.isArray(attr) && + attr[0] === ResponseAttributeTypes.async && + attr[1] + ) { + return attr + } + }) + return attr ? new AsyncAttribute(attr as AsyncResponseAttribute) : undefined + } +} diff --git a/src/message/attributes/async-attribute/index.ts b/src/message/attributes/async-attribute/index.ts new file mode 100644 index 0000000..f7af2ee --- /dev/null +++ b/src/message/attributes/async-attribute/index.ts @@ -0,0 +1 @@ +export { AsyncAttribute } from "./async-attribute" diff --git a/src/message/attributes/attributes.ts b/src/message/attributes/attributes.ts new file mode 100644 index 0000000..8802fe8 --- /dev/null +++ b/src/message/attributes/attributes.ts @@ -0,0 +1,16 @@ +import { Message } from ".." + +export class Attributes { + private attributes + constructor(attrs: unknown[]) { + this.attributes = attrs + } + getAttributes() { + return this.attributes + } + static getFromMessage(m: Message): Attributes | undefined { + const attrs = m.getContent().get(8) + if (Array.isArray(attrs)) return new Attributes(attrs) + return undefined + } +} diff --git a/src/message/attributes/index.ts b/src/message/attributes/index.ts new file mode 100644 index 0000000..36f2914 --- /dev/null +++ b/src/message/attributes/index.ts @@ -0,0 +1,3 @@ +export { Attributes } from "./attributes" +export * from "./async-attribute" +export * from "./types" diff --git a/src/message/attributes/types.ts b/src/message/attributes/types.ts new file mode 100644 index 0000000..06dbd3e --- /dev/null +++ b/src/message/attributes/types.ts @@ -0,0 +1,3 @@ +export enum ResponseAttributeTypes { + async = 1, +} diff --git a/src/message/cose.test.ts b/src/message/cose.test.ts index dd6674a..86c2e90 100644 --- a/src/message/cose.test.ts +++ b/src/message/cose.test.ts @@ -1,33 +1,37 @@ -import { Message } from "../message"; -import { CoseMessage, CoseKey } from "./cose"; -import { KeyPair } from "../keys"; +import { pki } from "node-forge" +import { Message } from "../message" +import { CoseMessage } from "./cose" +import { Ed25519KeyPairIdentity } from "../identity" +const ed25519 = pki.ed25519 describe("CoseMessage", () => { - test("can make anonymous request", () => { - const anonymousMessage = Message.fromObject({ method: "info" }); - const coseMessage = CoseMessage.fromMessage(anonymousMessage); + test("can make anonymous request", async () => { + const anonymousMessage = Message.fromObject({ method: "info" }) + const coseMessage = await CoseMessage.fromMessage(anonymousMessage) - expect(coseMessage.signature).toHaveLength(0); - }); + expect(coseMessage.signature).toHaveLength(0) + }) - test("can make signed request", () => { - const keys = KeyPair.fromMnemonic(KeyPair.getMnemonic()); - const signedMessage = Message.fromObject({ method: "info" }); - const coseMessage = CoseMessage.fromMessage(signedMessage, keys); + test("can make signed request", async () => { + const identity = Ed25519KeyPairIdentity.fromMnemonic( + Ed25519KeyPairIdentity.getMnemonic(), + ) + const signedMessage = Message.fromObject({ method: "info" }) + const coseMessage = await CoseMessage.fromMessage(signedMessage, identity) - expect(coseMessage.signature).not.toHaveLength(0); - }); + expect(coseMessage.signature).not.toHaveLength(0) + }) - test("can serialize/deserialize CBOR", () => { - const request = Message.fromObject({ method: "info" }); - const coseMessage = CoseMessage.fromMessage(request); - const serialized = coseMessage.toCborData(); - const deserialized = CoseMessage.fromCborData(serialized); + test("can serialize/deserialize CBOR", async () => { + const request = Message.fromObject({ method: "info" }) + const coseMessage = await CoseMessage.fromMessage(request) + const serialized = coseMessage.toCborData() + const deserialized = CoseMessage.fromCborData(serialized) - expect(deserialized.content).toStrictEqual(coseMessage.content); - }); -}); + expect(deserialized.content).toStrictEqual(coseMessage.content) + }) +}) describe("CoseKey", () => { - test.skip("fromPublicKey", () => {}); -}); + test.skip("fromPublicKey", () => {}) +}) diff --git a/src/message/cose.ts b/src/message/cose.ts index ddf680b..f6266b2 100644 --- a/src/message/cose.ts +++ b/src/message/cose.ts @@ -1,117 +1,116 @@ import cbor from "cbor"; -import { pki } from "node-forge"; import { sha3_224 } from "js-sha3"; -const ed25519 = pki.ed25519; - -import { Identity } from "../identity"; -import { Key, KeyPair } from "../keys"; -import { Message } from "../message"; -import { CborData, CborMap, tag } from "./cbor"; - - -const ANONYMOUS = Buffer.from([0x00]); -const EMPTY = Buffer.alloc(0); - +import { + Address, + AnonymousIdentity, + Identity, + PublicKeyIdentity, +} from "../identity" +import { Message } from "../message" +import { CborData, CborMap, tag } from "./cbor" + +export const ANONYMOUS = Buffer.from([0x00]) +export const EMPTY = Buffer.alloc(0) export class CoseMessage { - protectedHeader: CborMap; - unprotectedHeader: CborMap; - content: CborMap; - signature: Buffer; + protectedHeader: CborMap + unprotectedHeader: CborMap + content: CborMap + signature: ArrayBuffer constructor( protectedHeader: CborMap, unprotectedHeader: CborMap, content: CborMap, - signature: Buffer + signature: ArrayBuffer, ) { - this.protectedHeader = protectedHeader; - this.unprotectedHeader = unprotectedHeader; - this.content = content; - this.signature = signature; + this.protectedHeader = protectedHeader + this.unprotectedHeader = unprotectedHeader + this.content = content + this.signature = signature } static fromCborData(data: CborData): CoseMessage { const decoders = { tags: { - 10000: (value: Uint8Array) => new Identity(Buffer.from(value)), + 10000: (value: Uint8Array) => new Address(Buffer.from(value)), 1: (value: number) => tag(1, value), }, - }; - const cose = cbor.decodeFirstSync(data, decoders).value; - const protectedHeader = cbor.decodeFirstSync(cose[0]); - const unprotectedHeader = cose[1]; - const content = cbor.decodeFirstSync(cose[2], decoders).value; + } + const cose = cbor.decodeFirstSync(data, decoders).value + const protectedHeader = cbor.decodeFirstSync(cose[0]) + const unprotectedHeader = cose[1] + const content = cbor.decodeFirstSync(cose[2], decoders).value const signature = cose[3] return new CoseMessage( protectedHeader, unprotectedHeader, content, - signature - ); + signature, + ) } - static fromMessage(message: Message, keys?: KeyPair): CoseMessage { - const protectedHeader = this.getProtectedHeader( - keys ? keys.publicKey : ANONYMOUS - ); - const unprotectedHeader = this.getUnprotectedHeader(); - const content = message.content; - const signature = keys - ? this.getSignature(protectedHeader, content, keys.privateKey) - : EMPTY; + static async fromMessage( + message: Message, + identity: Identity = new AnonymousIdentity(), + ): Promise { + const protectedHeader = this.getProtectedHeader(identity) + const cborProtectedHeader = cbor.encodeCanonical(protectedHeader) + const content = message.getContent() + const cborContent = cbor.encode(tag(10001, content)) + const toBeSigned = cbor.encodeCanonical([ + "Signature1", + cborProtectedHeader, + EMPTY, + cborContent, + ]) + const unprotectedHeader = await identity.getUnprotectedHeader( + cborContent, + cborProtectedHeader, + ) + const signature = await identity.sign(toBeSigned, unprotectedHeader) + return new CoseMessage( protectedHeader, unprotectedHeader, content, - signature - ); - } - - private static getProtectedHeader(publicKey: Key): CborMap { - const coseKey = new CoseKey(publicKey); - const protectedHeader = new Map(); - protectedHeader.set(1, -8); // alg: "Ed25519" - protectedHeader.set(4, coseKey.keyId); // kid: kid - protectedHeader.set("keyset", coseKey.toCborData()); - return protectedHeader; + signature, + ) } - private static getUnprotectedHeader(): CborMap { - return new Map(); - } - - private static getSignature( - protectedHeader: CborMap, - content: CborMap, - privateKey: Key - ): Buffer { - const p = cbor.encodeCanonical(protectedHeader); - const payload = cbor.encode(tag(10001, content)); - const message = cbor.encodeCanonical(["Signature1", p, EMPTY, payload]); - return Buffer.from(ed25519.sign({ message, privateKey })); + private static getProtectedHeader( + identity: PublicKeyIdentity | Identity, + ): CborMap { + const protectedHeader = new Map() + if ("getCoseKey" in identity) { + const coseKey = identity.getCoseKey() + protectedHeader.set(1, coseKey.key.get(3)) // alg + protectedHeader.set(4, coseKey.keyId) // kid: kid + protectedHeader.set("keyset", coseKey.toCborData()) + } + return protectedHeader } private replacer(key: string, value: any) { if (value?.type === "Buffer") { - return Buffer.from(value.data).toString("hex"); + return Buffer.from(value.data).toString("hex") } else if (value instanceof Map) { - return Object.fromEntries(value.entries()); + return Object.fromEntries(value.entries()) } else if (typeof value === "bigint") { - return parseInt(value.toString()); + return parseInt(value.toString()) } else if (key === "hash") { - return Buffer.from(value).toString("hex"); + return Buffer.from(value).toString("hex") } else { - return value; + return value } } toCborData(): CborData { - const p = cbor.encodeCanonical(this.protectedHeader); - const u = this.unprotectedHeader; - const payload = cbor.encode(tag(10001, this.content)); - let sig = this.signature; - return cbor.encodeCanonical(tag(18, [p, u, payload, sig])); + const p = cbor.encodeCanonical(this.protectedHeader) + const u = this.unprotectedHeader + const payload = cbor.encode(tag(10001, this.content)) + let sig = this.signature + return cbor.encodeCanonical(tag(18, [p, u, payload, sig])) } toString(): string { @@ -123,52 +122,42 @@ export class CoseMessage { this.signature, ], this.replacer, - 2 - ); + 2, + ) } } export class CoseKey { - key: CborMap; - keyId: CborData; - private common: CborMap; - - constructor(publicKey: Key) { - this.common = this.getCommon(publicKey); - this.keyId = this.getKeyId(); - this.key = this.getKey(); - } - - private getCommon(publicKey: Key) { - const common = new Map(); - common.set(1, 1); // kty: OKP - common.set(3, -8); // alg: EdDSA - common.set(-1, 6); // crv: Ed25519 - common.set(4, [2]); // key_ops: [verify] - common.set(-2, publicKey); // x: publicKey - return common; + key: CborMap + keyId: CborData + private common: CborMap + + constructor(commonParams: Map = new Map()) { + this.common = commonParams + this.keyId = this.getKeyId() + this.key = this.getKey() } private getKeyId() { if (Buffer.compare(this.common.get(-2), ANONYMOUS) === 0) { - return ANONYMOUS; + return ANONYMOUS } - const keyId = new Map(this.common); - const pk = "01" + sha3_224(cbor.encodeCanonical(keyId)); - return Buffer.from(pk, "hex"); + const keyId = new Map(this.common) + const pk = "01" + sha3_224(cbor.encodeCanonical(keyId)) + return Buffer.from(pk, "hex") } private getKey() { - const key = new Map(this.common); - key.set(2, this.keyId); // kid: Key ID - return key; + const key = new Map(this.common) + key.set(2, this.keyId) // kid: Key ID + return key } toCborData(): CborData { - return cbor.encodeCanonical([this.key]); + return cbor.encodeCanonical([this.key]) } - toIdentity(): Identity { - return new Identity(this.keyId); + toAddress(): Address { + return new Address(this.keyId) } } diff --git a/src/message/index.ts b/src/message/index.ts index d3ce69d..4ddb55f 100644 --- a/src/message/index.ts +++ b/src/message/index.ts @@ -1,15 +1,14 @@ import cbor from "cbor"; - -import { Identity } from "../identity"; +import { Address, Identity } from "../identity" import { CborData, CborMap, tag } from "./cbor"; import { CoseMessage } from "./cose"; import { ManyError, SerializedManyError } from "./error"; -import { KeyPair } from "../keys"; +import { Attributes, AsyncAttribute } from "./attributes" interface MessageContent { version?: number; - from?: Identity; - to?: Identity; + from?: Address + to?: Address method: string; data?: any; timestamp?: number; @@ -19,7 +18,25 @@ interface MessageContent { } export class Message { - constructor(public content: CborMap) {} + private content: CborMap + constructor(content: CborMap) { + this.content = content + } + + getContent() { + return this.content + } + + getAsyncToken(): ArrayBuffer | undefined { + const attributes = Attributes.getFromMessage(this) + return attributes + ? AsyncAttribute.getFromAttributes(attributes)?.getToken() + : undefined + } + + getPayload(): CborMap { + return cbor.decode(this.content?.get(4)) + } static fromObject(obj: MessageContent): Message { if (!obj.method) { @@ -85,12 +102,12 @@ export class Message { } } - toCoseMessage(keys?: KeyPair) { - return CoseMessage.fromMessage(this, keys); + toCoseMessage(identity?: Identity) { + return CoseMessage.fromMessage(this, identity) } - toCborData(keys?: KeyPair) { - return this.toCoseMessage(keys).toCborData(); + async toCborData(identity?: Identity) { + return (await this.toCoseMessage(identity)).toCborData() } toString() { diff --git a/src/message/message.test.ts b/src/message/message.test.ts index 252ce58..95b8ac7 100644 --- a/src/message/message.test.ts +++ b/src/message/message.test.ts @@ -7,11 +7,11 @@ describe("Message", () => { expect(req).toHaveProperty("content"); }); - test("can be serialized/deserialized", () => { - const msg = { method: "info" }; - const req = Message.fromObject(msg); - const cbor = req.toCborData(); + test("can be serialized/deserialized", async () => { + const msg = { method: "info" } + const req = Message.fromObject(msg) + const cbor = await req.toCborData() - expect(Message.fromCborData(cbor)).toStrictEqual(req); - }); + expect(Message.fromCborData(cbor)).toStrictEqual(req) + }) }); diff --git a/src/network/index.ts b/src/network/index.ts index e5d7800..946203c 100644 --- a/src/network/index.ts +++ b/src/network/index.ts @@ -1,2 +1,2 @@ -export { Network } from "./network" +export * from "./network" export * from "./modules" diff --git a/src/network/modules/async/__tests__/async.test.ts b/src/network/modules/async/__tests__/async.test.ts new file mode 100644 index 0000000..4360f12 --- /dev/null +++ b/src/network/modules/async/__tests__/async.test.ts @@ -0,0 +1,56 @@ +import { Async } from "../async" +import { Message } from "../../../../message" +import { tag } from "../../../../message/cbor" +import cbor from "cbor" +import { Network } from "../../.." +import { AnonymousIdentity } from "../../../../identity" +import { AsyncStatusResult } from ".." + +describe("Async", () => { + it("handleAsyncToken()", async () => { + const mockCall = jest + .fn() + .mockImplementationOnce(async () => { + return makeAsyncStatusPollResponseMessage(AsyncStatusResult.Unknown) + }) + .mockImplementationOnce(async () => { + return makeAsyncStatusPollResponseMessage( + AsyncStatusResult.Done, + cbor.encode( + tag( + 10002, + new Map([[4, cbor.encode(new Map([[0, ["data", "returned"]]]))]]), + ), + ), + ) + }) + const async = setupAsync(mockCall) + const n = new Network("/api", new AnonymousIdentity()) + n.call = mockCall + const res = (await async.handleAsyncToken( + new Message(new Map([[8, [[1, new ArrayBuffer(0)]]]])), + n, + )) as Message + const content = res.getPayload() + expect(content instanceof Map).toBe(true) + expect(content.get(0)).toEqual(["data", "returned"]) + }) +}) + +function setupAsync(callImpl?: jest.Mock) { + const mockCall = callImpl ?? jest.fn() + return { + call: mockCall, + ...Async, + } +} + +function makeAsyncStatusPollResponseMessage( + statusResult: AsyncStatusResult, + payload?: ArrayBuffer, +) { + const result = new Map() + result.set(0, statusResult) + if (payload) result.set(1, payload) + return new Message(new Map([[4, cbor.encode(result)]])) +} \ No newline at end of file diff --git a/src/network/modules/async/async.ts b/src/network/modules/async/async.ts new file mode 100644 index 0000000..291108b --- /dev/null +++ b/src/network/modules/async/async.ts @@ -0,0 +1,93 @@ +import cbor from "cbor" +import { ONE_MINUTE, ONE_SECOND } from "../../../const" +import { AnonymousIdentity } from "../../../identity" +import { Message } from "../../../message" +import { CborMap } from "../../../message/cbor" +import { Network } from "../../network" +import type { NetworkModule } from "../types" + +const sleep = async (time: number) => new Promise(r => setTimeout(r, time)) + +interface Async extends NetworkModule { + handleAsyncToken: (message: Message, n?: Network) => Promise +} + +export const Async: Async = { + _namespace_: "async", + + async handleAsyncToken(message: Message, n?: Network) { + const asyncToken = message.getAsyncToken() + return asyncToken + ? await pollAsyncStatus( + n ?? new Network(this.url, new AnonymousIdentity()), + asyncToken, + ) + : message + }, +} + +export enum AsyncStatusResult { + Unknown = 0, + Queued = 1, + Processing = 2, + Done = 3, + Expired = 4, +} + +type AsyncStatusPayload = + | { result: AsyncStatusResult.Unknown } + | { result: AsyncStatusResult.Queued } + | { result: AsyncStatusResult.Processing } + | { result: AsyncStatusResult.Expired } + | { + result: AsyncStatusResult.Done + payload: ArrayBuffer + } +function parseAsyncStatusPayload(cbor: CborMap): AsyncStatusPayload { + const index = cbor.get(0) + if (typeof index != "number") { + throw new Error("Invalid async result") + } + if (!(index in AsyncStatusResult)) { + throw Error("Invalid async result") + } + const result = index as AsyncStatusResult + let payload = undefined + if (result === AsyncStatusResult.Done) { + payload = cbor.get(1) as ArrayBuffer + if (!payload || !Buffer.isBuffer(payload)) { + throw Error("Invalid async result") + } + } + return { result, payload } as AsyncStatusPayload +} +async function pollAsyncStatus( + n: Network, + asyncToken: ArrayBuffer, + options: { + timeoutInMsec?: number + waitTimeInMsec?: number + } = {}, +): Promise { + const timeoutInMsec = options.timeoutInMsec ?? ONE_MINUTE + let waitTimeInMsec = options.waitTimeInMsec ?? ONE_SECOND + const end = +new Date() + timeoutInMsec + while (true) { + const res = (await n.call( + "async.status", + new Map([[0, asyncToken]]), + )) as Message + const result = parseAsyncStatusPayload(res.getPayload()) + switch (result.result) { + case AsyncStatusResult.Done: + return new Message(cbor.decode(result.payload).value) + case AsyncStatusResult.Expired: + throw new Error("Async Expired before getting a result") + } + if (Date.now() >= end) { + return res + } + await sleep(waitTimeInMsec) + waitTimeInMsec *= 1.5 + } +} \ No newline at end of file diff --git a/src/network/modules/async/index.ts b/src/network/modules/async/index.ts new file mode 100644 index 0000000..ee10b57 --- /dev/null +++ b/src/network/modules/async/index.ts @@ -0,0 +1 @@ +export * from "./async" diff --git a/src/network/modules/id-store/__tests__/data.ts b/src/network/modules/id-store/__tests__/data.ts new file mode 100644 index 0000000..a61c894 --- /dev/null +++ b/src/network/modules/id-store/__tests__/data.ts @@ -0,0 +1,23 @@ +import cbor from "cbor" +import { Message } from "../../../../message" + +export const mockStoreResponseContent = new Map([ + [4, cbor.encode(new Map([[0, ["recovery", "phrase"]]]))], +]) +export const mockStoreResponseMessage = new Message(mockStoreResponseContent) + +export const mockGetCredentialResponseContent = new Map([ + [ + 4, + cbor.encode( + // @ts-ignore + new Map([ + [0, new ArrayBuffer(32)], + [1, new ArrayBuffer(32)], + ]), + ), + ], +]) +export const mockGetCredentialResponseMessage = new Message( + mockGetCredentialResponseContent, +) diff --git a/src/network/modules/id-store/__tests__/id-store.test.ts b/src/network/modules/id-store/__tests__/id-store.test.ts new file mode 100644 index 0000000..ee25e88 --- /dev/null +++ b/src/network/modules/id-store/__tests__/id-store.test.ts @@ -0,0 +1,70 @@ +import { IdStore } from "../id-store" +import { + mockStoreResponseMessage, + mockGetCredentialResponseMessage, +} from "./data" + +describe("IdStore", () => { + describe("idstore.store()", () => { + it("returns symbols", async () => { + const mockCall = jest.fn(async () => { + return mockStoreResponseMessage + }) + const m = new Map() + m.set(0, "ma123") + m.set(1, Buffer.from(new ArrayBuffer(32))) + m.set(2, new ArrayBuffer(32)) + const idStore = setupIdStore(mockCall) + const res = await idStore.store( + "ma123", + new ArrayBuffer(32), + new ArrayBuffer(32), + ) + expect(mockCall).toHaveBeenCalledTimes(1) + expect(mockCall).toHaveBeenCalledWith("idstore.store", m) + expect(res).toEqual({ phrase: "recovery phrase" }) + }) + }) + describe("idstore.getFromRecallPhrase()", () => { + it("returns cosePublicKey and credentialId", async () => { + const mockCall = jest.fn(async () => { + return mockGetCredentialResponseMessage + }) + const m = new Map() + m.set(0, ["recall", "phrase"]) + const idStore = setupIdStore(mockCall) + const res = await idStore.getFromRecallPhrase("recall phrase") + expect(mockCall).toHaveBeenCalledTimes(1) + expect(mockCall).toHaveBeenCalledWith("idstore.getFromRecallPhrase", m) + expect(res).toEqual({ + credentialId: Buffer.from(new ArrayBuffer(32)), + cosePublicKey: Buffer.from(new ArrayBuffer(32)), + }) + }) + }) + describe("idstore.getFromAddress()", () => { + it("returns cosePublicKey and credentialId", async () => { + const mockCall = jest.fn(async () => { + return mockGetCredentialResponseMessage + }) + const m = new Map() + m.set(0, "ma123") + const idStore = setupIdStore(mockCall) + const res = await idStore.getFromAddress("ma123") + expect(mockCall).toHaveBeenCalledTimes(1) + expect(mockCall).toHaveBeenCalledWith("idstore.getFromAddress", m) + expect(res).toEqual({ + credentialId: Buffer.from(new ArrayBuffer(32)), + cosePublicKey: Buffer.from(new ArrayBuffer(32)), + }) + }) + }) +}) + +function setupIdStore(callImpl?: jest.Mock) { + const mockCall = callImpl ?? jest.fn() + return { + call: mockCall, + ...IdStore, + } +} diff --git a/src/network/modules/id-store/id-store.ts b/src/network/modules/id-store/id-store.ts new file mode 100644 index 0000000..dcaedfe --- /dev/null +++ b/src/network/modules/id-store/id-store.ts @@ -0,0 +1,77 @@ +import cbor from "cbor" +import { Message } from "../../../message" +import type { NetworkModule } from "../types" + +export type GetPhraseReturnType = ReturnType + +export type GetCredentialDataReturnType = ReturnType + +interface IdStore extends NetworkModule { + store: ( + address: string, + credId: ArrayBuffer, + credCosePublicKey: ArrayBuffer, + ) => Promise + getFromRecallPhrase: (phrase: string) => Promise + getFromAddress: (address: string) => Promise +} + +export const IdStore: IdStore = { + _namespace_: "idStore", + + async store( + address: string, + credId: ArrayBuffer, + credCosePublicKey: ArrayBuffer, + ): Promise { + const m = new Map() + m.set(0, address) + m.set(1, Buffer.from(credId)) + m.set(2, credCosePublicKey) + const message = await this.call("idstore.store", m) + return getPhrase(message) + }, + + async getFromRecallPhrase( + phrase: string, + ): Promise { + const val = phrase.trim().split(" ") + const message = await this.call( + "idstore.getFromRecallPhrase", + new Map([[0, val]]), + ) + return getCredentialData(message) + }, + + async getFromAddress(address: string): Promise { + const message = await this.call( + "idstore.getFromAddress", + new Map([[0, address]]), + ) + return getCredentialData(message) + }, +} + +function getPhrase(m: Message): { phrase: string } { + const result = { phrase: "" } + const payload = m.getPayload() + if (payload) { + result.phrase = payload?.get(0)?.join(" ") + } + return result +} + +function getCredentialData(m: Message): { + credentialId?: ArrayBuffer + cosePublicKey?: ArrayBuffer +} { + const result = { credentialId: undefined, cosePublicKey: undefined } + const payload = m.getPayload() + if (payload) { + if (payload.has(0) && payload.has(1)) { + result.credentialId = payload.get(0) + result.cosePublicKey = payload.get(1) + } + } + return result +} diff --git a/src/network/modules/id-store/index.ts b/src/network/modules/id-store/index.ts new file mode 100644 index 0000000..865504a --- /dev/null +++ b/src/network/modules/id-store/index.ts @@ -0,0 +1 @@ +export * from "./id-store" diff --git a/src/network/modules/index.ts b/src/network/modules/index.ts index 129c955..d6de763 100644 --- a/src/network/modules/index.ts +++ b/src/network/modules/index.ts @@ -2,3 +2,4 @@ export * from "./ledger" export * from "./kvStore" export * from "./blockchain" export * from "./types" +export * from "./id-store" diff --git a/src/network/modules/ledger/__tests__/data.ts b/src/network/modules/ledger/__tests__/data.ts index 9599670..0f52a5b 100644 --- a/src/network/modules/ledger/__tests__/data.ts +++ b/src/network/modules/ledger/__tests__/data.ts @@ -1,16 +1,16 @@ -import { Identity } from "../../../../identity" +import { Address } from "../../../../identity" import cbor from "cbor" import { tag } from "../../../../message/cbor" import { Message } from "../../../../message" const identityStr1 = "mqbfbahksdwaqeenayy2gxke32hgb7aq4ao4wt745lsfs6wiaaaaqnz" -const Identity1 = Identity.fromString(identityStr1).toBuffer() +const Address1 = Address.fromString(identityStr1).toBuffer() const identityStr2 = "maffbahksdwaqeenayy2gxke32hgb7aq4ao4wt745lsfs6wijp" -const Identity2 = Identity.fromString(identityStr2).toBuffer() +const Address2 = Address.fromString(identityStr2).toBuffer() -export const mockSymbolIdentity = [tag(10000, Identity1), "abc"] +export const mockSymbolAddress = [tag(10000, Address1), "abc"] -export const mockSymbolIdentity2 = [tag(10000, Identity2), "cba"] +export const mockSymbolAddress2 = [tag(10000, Address2), "cba"] export const expectedSymbolsMap = { symbols: new Map([ @@ -27,7 +27,7 @@ export const mockLedgerInfoResponseContent = new Map([ [ 4, // @ts-ignore - new Map([mockSymbolIdentity, mockSymbolIdentity2]), + new Map([mockSymbolAddress, mockSymbolAddress2]), ], ]), ), @@ -38,8 +38,8 @@ export const mockLedgerInfoResponseMessage = new Message( mockLedgerInfoResponseContent, ) -export const mockSymbolBalance = [tag(10000, Identity1), 1000000] -export const mockSymbolBalance2 = [tag(10000, Identity2), 5000000] +export const mockSymbolBalance = [tag(10000, Address1), 1000000] +export const mockSymbolBalance2 = [tag(10000, Address2), 5000000] export const mockLedgerBalanceResponseContent = new Map([ [ @@ -65,8 +65,8 @@ export const expectedBalancesMap = { ]), } -const txnSymbolIdentity1 = "oafw3bxrqe2jdcidvjlonloqcczvytrxr3fl4naybmign3uy6e" -const txnSymbolIdentity2 = "oafxombm6axwsrcvymht5ss3chlpbks7sp7dvl2v7chnuzkyfj" +const txnSymbolAddress1 = "mafw3bxrqe2jdcidvjlonloqcczvytrxr3fl4naybmign3uy6e" +const txnSymbolAddress2 = "mafxombm6axwsrcvymht5ss3chlpbks7sp7dvl2v7chnuzkyfj" const txnTime1 = new Date() const txnTime2 = new Date() txnTime2.setMinutes(txnTime1.getMinutes() + 1) @@ -86,13 +86,20 @@ export const mockLedgerListResponseContent = new Map([ [1, txnTime1], [ 2, - [ - 0, - tag(10000, Identity1), - tag(10000, Identity2), - txnSymbolIdentity1, - 1, - ], + //@ts-ignore + new Map([ + [0, 0], + [1, tag(10000, Address1)], + [2, tag(10000, Address2)], + [ + 3, + tag( + 10000, + Address.fromString(txnSymbolAddress1).toBuffer(), + ), + ], + [4, 1], + ]), ], ]), // @ts-ignore @@ -101,13 +108,20 @@ export const mockLedgerListResponseContent = new Map([ [1, txnTime2], [ 2, - [ - 0, - tag(10000, Identity1), - tag(10000, Identity2), - txnSymbolIdentity2, - 2, - ], + //@ts-ignore + new Map([ + [0, 0], + [1, tag(10000, Address1)], + [2, tag(10000, Address2)], + [ + 3, + tag( + 10000, + Address.fromString(txnSymbolAddress2).toBuffer(), + ), + ], + [4, 2], + ]), ], ]), ], @@ -126,7 +140,7 @@ export const expectedListResponse = { type: "send", from: identityStr1, to: identityStr2, - symbolIdentity: txnSymbolIdentity1, + symbolAddress: txnSymbolAddress1, amount: BigInt(1), }, { @@ -135,7 +149,7 @@ export const expectedListResponse = { type: "send", from: identityStr1, to: identityStr2, - symbolIdentity: txnSymbolIdentity2, + symbolAddress: txnSymbolAddress2, amount: BigInt(2), }, ], diff --git a/src/network/modules/ledger/__tests__/ledger.test.ts b/src/network/modules/ledger/__tests__/ledger.test.ts index ae5ea34..449e82c 100644 --- a/src/network/modules/ledger/__tests__/ledger.test.ts +++ b/src/network/modules/ledger/__tests__/ledger.test.ts @@ -54,7 +54,7 @@ describe("Ledger", () => { return mockLedgerBalanceResponseMessage }) const ledger = setupLedger(mockCall) - await ledger.balance(["abc", "def"]) + await ledger.balance(undefined, ["abc", "def"]) expect(mockCall).toHaveBeenCalledTimes(1) expect(mockCall).toHaveBeenCalledWith( "ledger.balance", diff --git a/src/network/modules/ledger/ledger.ts b/src/network/modules/ledger/ledger.ts index 621f6c8..cc89da6 100644 --- a/src/network/modules/ledger/ledger.ts +++ b/src/network/modules/ledger/ledger.ts @@ -1,10 +1,10 @@ import cbor from "cbor" -import { Identity } from "../../../identity" +import { Address, Identity } from "../../../identity" import { Message } from "../../../message" import type { NetworkModule } from "../types" export interface LedgerInfo { - symbols: Map, string> + symbols: Map, string> } export enum OrderType { @@ -42,7 +42,7 @@ export interface Transaction { time: Date type: TransactionType amount: bigint - symbolIdentity: string + symbolAddress: string symbol?: string from?: string to?: string @@ -73,10 +73,10 @@ export enum RangeType { interface Ledger extends NetworkModule { _namespace_: string info: () => Promise - balance: (symbols?: string[]) => Promise + balance: (address?: string, symbols?: string[]) => Promise mint: () => Promise burn: () => Promise - send: (to: Identity, amount: bigint, symbol: string) => Promise + send: (to: Address, amount: bigint, symbol: string) => Promise transactions: () => Promise<{ count: bigint }> list: (opts?: ListArgs) => Promise } @@ -87,11 +87,10 @@ export const Ledger: Ledger = { const message = await this.call("ledger.info") return getLedgerInfo(message) }, - async balance(symbols?: string[]): Promise { - const res = await this.call( - "ledger.balance", - new Map([[1, symbols ? symbols : []]]), - ) + async balance(address?: string, symbols?: string[]): Promise { + const m = new Map([[1, symbols ?? []]]) + address && m.set(0, address) + const res = await this.call("ledger.balance", m) return getBalance(res) }, @@ -103,7 +102,7 @@ export const Ledger: Ledger = { throw new Error("Not implemented") }, - async send(to: Identity, amount: bigint, symbol: string): Promise { + async send(to: Address, amount: bigint, symbol: string): Promise { return await this.call( "ledger.send", new Map([ @@ -139,15 +138,15 @@ export const Ledger: Ledger = { export function getLedgerInfo(message: Message): LedgerInfo { const result: LedgerInfo = { symbols: new Map() } - if (message.content.has(4)) { - const decodedContent = cbor.decodeFirstSync(message.content.get(4)) + const decodedContent = message.getPayload() + if (decodedContent) { if (decodedContent.has(4)) { const symbols = decodedContent.get(4) for (const symbol of symbols) { - const identity = new Identity(Buffer.from(symbol[0].value)).toString() + const address = new Address(Buffer.from(symbol[0].value)).toString() const symbolName = symbol[1] - result.symbols.set(identity, symbolName) + result.symbols.set(address, symbolName) } } } @@ -160,16 +159,14 @@ export interface Balances { export function getBalance(message: Message): Balances { const result = { balances: new Map() } - if (message.content.has(4)) { - const messageContent = cbor.decodeFirstSync(message.content.get(4)) - if (messageContent.has(0)) { - const symbolsToBalancesMap = messageContent.get(0) - if (!(symbolsToBalancesMap instanceof Map)) return result - for (const balanceEntry of symbolsToBalancesMap) { - const symbolIdentityStr = new Identity(balanceEntry[0].value).toString() - const balance = balanceEntry[1] - result.balances.set(symbolIdentityStr, balance) - } + const messageContent = message.getPayload() + if (messageContent && messageContent.has(0)) { + const symbolsToBalancesMap = messageContent.get(0) + if (!(symbolsToBalancesMap instanceof Map)) return result + for (const balanceEntry of symbolsToBalancesMap) { + const symbolAddress = new Address(balanceEntry[0].value).toString() + const balance = balanceEntry[1] + result.balances.set(symbolAddress, balance) } } return result @@ -177,9 +174,7 @@ export function getBalance(message: Message): Balances { function getTransactionsCount(message: Message) { return { - count: message?.content?.has(4) - ? cbor.decodeFirstSync(message.content.get(4))?.get(0) - : 0, + count: message?.getContent().has(4) ? message?.getPayload()?.get(0) : 0, } } @@ -188,13 +183,13 @@ function getTxnList(message: Message): TransactionsData { count: 0, transactions: [], } - if (message.content.has(4)) { - const decodedContent = cbor.decodeFirstSync(message.content.get(4)) + const decodedContent = message.getPayload() + if (decodedContent) { result.count = decodedContent.get(0) const transactions = decodedContent.get(1) result.transactions = transactions.map((t: Map) => { - let transactionData = t.get(2) as Array - const transactionType = transactionData[0] + let transactionData = t.get(2) as Map + const transactionType = transactionData.get(0) if (transactionType === 0) { return makeSendTransactionData(t) } @@ -204,21 +199,23 @@ function getTxnList(message: Message): TransactionsData { } function makeSendTransactionData(t: Map) { - let transactionData = t.get(2) as Array + let transactionData = t.get(2) as Map const id = t.get(0) as Uint8Array const time = t.get(1) - const from = transactionData[1] as { value: Uint8Array } - const to = transactionData[2] as { value: Uint8Array } - const fromAddress = new Identity(from.value as Buffer).toString() - const toAddress = new Identity(to.value as Buffer).toString() + const from = transactionData.get(1) as { value: Uint8Array } + const to = transactionData.get(2) as { value: Uint8Array } + const symbol = transactionData.get(3) as { value: Uint8Array } + const symbolAddress = new Address(symbol.value as Buffer).toString() + const fromAddress = new Address(from.value as Buffer).toString() + const toAddress = new Address(to.value as Buffer).toString() return { id, time, type: TransactionType.send, from: fromAddress, to: toAddress, - symbolIdentity: transactionData[3], - amount: BigInt(transactionData[4] as number), + symbolAddress, + amount: BigInt(transactionData.get(4) as number), } } diff --git a/src/network/network.test.ts b/src/network/network.test.ts index f202420..94ea85c 100644 --- a/src/network/network.test.ts +++ b/src/network/network.test.ts @@ -1,14 +1,14 @@ import cbor from "cbor"; import { Network } from "../network"; -import { KeyPair } from "../keys"; import { tag } from "../message/cbor"; import { sha3_224 } from "js-sha3"; import { expectedSymbolsMap, - mockSymbolIdentity, - mockSymbolIdentity2, + mockSymbolAddress, + mockSymbolAddress2, } from "./modules/ledger/__tests__/data" import { Ledger } from "./modules"; +import { Ed25519KeyPairIdentity } from "../identity" const globalFetch = global.fetch; @@ -19,7 +19,9 @@ describe("network", () => { test("can get and set URL and KeyPair", () => { const testnet = new Network("http://example.com"); - const keys = KeyPair.fromMnemonic(KeyPair.getMnemonic()); + const keys = Ed25519KeyPairIdentity.fromMnemonic( + Ed25519KeyPairIdentity.getMnemonic(), + ) testnet.keys = keys; expect(testnet.url).toBe("http://example.com"); @@ -71,10 +73,10 @@ describe("network", () => { 4, cbor.encode( // @ts-ignore - new Map([[4, new Map([mockSymbolIdentity, mockSymbolIdentity2])]]) + new Map([[4, new Map([mockSymbolAddress, mockSymbolAddress2])]]), ), ], - ]); + ]) const mockCoseResponse = cbor.encodeCanonical( tag(18, [ cbor.encodeCanonical(protectedHeader), diff --git a/src/network/network.ts b/src/network/network.ts index 546b423..ffd3bf4 100644 --- a/src/network/network.ts +++ b/src/network/network.ts @@ -1,18 +1,18 @@ import { Identity } from "../identity" -import { KeyPair } from "../keys" import { Message } from "../message" import { CborData } from "../message/cbor" import { applyMixins } from "../utils" import { NetworkModule } from "./modules" +import { Async } from "./modules/async" export class Network { [k: string]: any url: string - keys: KeyPair | undefined + identity: Identity | undefined - constructor(url: string, keys?: KeyPair) { + constructor(url: string, identity?: Identity) { this.url = url - this.keys = keys + this.identity = identity } apply(modules: NetworkModule[]) { @@ -20,10 +20,13 @@ export class Network { } async send(req: Message) { - const cbor = req.toCborData(this.keys) + const cbor = await req.toCborData(this.identity) const reply = await this.sendEncoded(cbor) // @TODO: Verify response - const res = Message.fromCborData(reply) + const res = await Async.handleAsyncToken.call( + this, + Message.fromCborData(reply), + ) return res } @@ -37,15 +40,13 @@ export class Network { return Buffer.from(reply) } - call(method: string, data?: any) { + async call(method: string, data?: any) { const req = Message.fromObject({ method, - from: this.keys?.publicKey - ? Identity.fromPublicKey(this.keys.publicKey) - : undefined, + from: this.identity ? await this.identity.getAddress() : undefined, data, }) - return this.send(req) + return await this.send(req) } // @TODO: Move these methods to modules/base, modules/ledger, etc. diff --git a/tsconfig.json b/tsconfig.json index 8234c6e..b6a7f67 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "resolveJsonModule": true, "skipLibCheck": true, "strict": true, - "target": "es6" + "target": "ES2017" }, "include": ["**/*.d.ts", "src/**/*", "test/**/*"], "exclude": [