From d69fce99533b312ef94544fbec3f6db7542c9fdb Mon Sep 17 00:00:00 2001 From: James Walker Date: Thu, 17 Mar 2022 12:25:11 -0400 Subject: [PATCH] App owned: account linking (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add producer and consumer modules and pin challenge * Add account delegation and device linking * Update user challenge naming and type * Add consumer onCompletion callback * Add start config types and fine tune onCompletion Add onCompletion to producer. The producer implicitly knows it is done after the challenge, but in the future the producer may stay active for multiple account link requests for consumers. The producer onCompletion will be useful then as a signal that all link requests have been handled. * Change delegateAccount and linkDevice types These are the paired functions that developers will implement. delegateAccount returns a record and linkDevice takes a record. It will be up to developers to make sure they the records match. * Add username to producer onCompletion Returning the username is mostly a convenience for developers, but moving it to the link state might prove useful for multiple accounts at some point in the future. Also, a small change in the consuer to reset the username on reseting the link state. It's not clear yet whether this should also happen in the producer, and in general whether the producer should keep listening after a consumer has been linked. * Convert callback interface to event emitter Instead of expecting callbacks to handle events, the event emitter lets a developer add event listeners. This interface is more flexible for developers and for our internal implementation -- developers can subscribe to events multiple times and we can add events in the future if we want to. At the moment, this is incomplete and we need to handle timeouts and think about cancellation semantics. Various edge cases should also be considered. * Add account linking declined message The consumer should be informed when linking was declined. A message was added when a proder declines, and an approved field was added to the link event of both the consumer and producer. * Refactor to remove module global state and more We want to avoid module global state because it may modules may be re-instantiated in some cases, which can produce unpleasant and hard to debug situations. This refactor also removes channel and user interaction side effects from many of the account linking functions, which should make them easier to test. Lastly, the dependency injected channel functions are reduced to a single createChannel function. It generates send, receive, and close functions. At the moment, the receive function is unused and maybe won't be needed. * Short circuit dispatch when no listeners An event is only created when we have at least one listener. This change prevents dispatch for events when no listeners have been added. * Add error handling * Decouple linking functions from linking state The functions are easier to test if they do not depend on the entire linking state. In addition, this will make it easier to re-use them for other AWAKE use cases. * Export linking functions for testing * Add unit tests and prop checks * Add producer state transition to delegation * Add consumer temporary key exchange retry We can't be certain that users will open a window with the producer before they request account linking in a consumer. The retry keeps trying until the consumer receives a session key from a producer. In addition, retries will be useful in cases where the producer is busy with another consumer, at least in this version. In a future version, the producer will queue up consumers that want to be linked. * Add consumer broadcast state warning There is a narrow window between when the consumer is initialized and they have broadcast a temporary RSA key. This warning will be triggered in that window and any message dropped. * Add producer warning for spurious DID messages A producer may be in the midst of linking a consumer when a request to link arrives from another consumer. The request is a DID string and we can implicitly ignore it when JSON.parse throws and display a warning instead. * Add producer check for stored username * Add received message while delegating warning * Add producer linking preflight We want to check that the producer can actually delegate the account before broadcast. The producer can delegate if it has the original keypair the account was created with or if it has a UCAN that delegates it SUPER_USER capabilities. In the first case, we check that the public key matches the DID in DNS. In the second case, we check the root issuer matches the DID in DNS and capabilities are granted in the UCAN. * Add websocket data type * Dependency inject checkCapability The checkCapability functions checks whether a producer can delegate an account given a username. The semantics of "can delegate" will depend on the implementations of the delegateAccount and linkDevice functions. Both delegateAccount and linkDevice are dependency injected, and making checkCapability dependency injectable lets implementers align all three functions. * Add username param to delegateAccount We currently set the username internally in storage at webnative.auth_username, but developers want to use username in other ways when linking a device. We pass the username into delegateAccount, which can then forward it to linkDevice during the linking process. * Print linking warnings when debug enabled * Convert confirm and reject pin to call delegation Returning a function from delegateAccount and declineDelegation was problematic because confirm or reject pin would call the returned functions multiple times. Wrapping the call internally works better and will be easier to test. * Add tryParseMessage guard We want to guard message when they expect JSON with a specific shape, but instead receive a string (a temporary DID) or a message that doesn't have the shape they expect. These cases warn because they are likely noise on the channel. A few test cases updated and fixed here as well. * Add more integration tests * Re-export account linking functions from top level The consumer closed in prematuraly in warning cases. Moved its done call to when and if it has successfully linked. * Add linkDevice unit tests * Add delegateAccount and declineDelegation unit tests * Rename and export provider and requestor types * Add auth lobby DI implementation * Add docstrings to external calls * Mark multiple consumer test as skipped We want to keep this test around for when we implement account linking message queues, but it is inconsistent and we should skip it for now. * Update changelog * Update version * Rename WebSocketData to ChannelData * Add once guard to confirm and reject pin We only want these functions to be called once and only one or the other should be called. * Rename createChannel to createWssChannel * Decrease number of filesystem API test runs * Move LinkingStep type to common linking module * Fix account registration exports Destructuring the implemenation and exporting its functions does not work because it only happens when the module evaluates. Instead, we can export wrapped versions of the implementation functions. * Rename provider and requestor Rename provider to producer and requestor to consumer. Export them at the top level under account instead of auth. * Convert linking steps to an enum * Remove unused temporaryRsaPair from producer * Use event maps (typed EventEmitter) * Convert event emitter to node style interface Co-authored-by: Brian Ginsburg Co-authored-by: Philipp Krüger --- CHANGELOG.md | 4 + package.json | 2 +- src/auth/channel.ts | 72 +++++ src/auth/implementation/types.ts | 5 + src/auth/index.ts | 16 ++ src/auth/internal.ts | 21 +- src/auth/linking.ts | 66 +++++ src/auth/linking/consumer.test.ts | 442 ++++++++++++++++++++++++++++++ src/auth/linking/consumer.ts | 292 ++++++++++++++++++++ src/auth/linking/producer.test.ts | 258 +++++++++++++++++ src/auth/linking/producer.ts | 288 +++++++++++++++++++ src/auth/lobby.ts | 61 ++++- src/auth/local.ts | 71 ++++- src/common/debug.ts | 4 + src/common/event-emitter.ts | 32 +++ src/common/types.ts | 4 + src/common/version.ts | 2 +- src/index.ts | 1 + tests/auth/linking.node.test.ts | 186 +++++++++++++ tests/fs/api.private.node.test.ts | 2 +- tests/fs/api.public.node.test.ts | 2 +- 21 files changed, 1821 insertions(+), 10 deletions(-) create mode 100644 src/auth/channel.ts create mode 100644 src/auth/index.ts create mode 100644 src/auth/linking.ts create mode 100644 src/auth/linking/consumer.test.ts create mode 100644 src/auth/linking/consumer.ts create mode 100644 src/auth/linking/producer.test.ts create mode 100644 src/auth/linking/producer.ts create mode 100644 src/common/event-emitter.ts create mode 100644 tests/auth/linking.node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c90f38ad..e3f589da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog +### v0.32.0 + +- Adds app owned account linking + ### v0.31.1 Move `madge` and `typedoc-plugin-missing-exports` from `dependencies` into `devDependencies`. diff --git a/package.json b/package.json index a9da4ec8a..2274101de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webnative", - "version": "0.31.1", + "version": "0.32.0", "description": "Fission Webnative SDK", "keywords": [ "WebCrypto", diff --git a/src/auth/channel.ts b/src/auth/channel.ts new file mode 100644 index 000000000..b19ea25fb --- /dev/null +++ b/src/auth/channel.ts @@ -0,0 +1,72 @@ +import * as did from "../did/index.js" +import { setup } from "../setup/internal.js" +import { LinkingError } from "./linking.js" + +import type { Maybe } from "../common/index.js" + +export type Channel = { + send: (data: ChannelData) => void + close: () => void +} + +export type ChannelOptions = { + username: string + handleMessage: (event: MessageEvent) => void +} + +export type ChannelData = string | ArrayBufferLike | Blob | ArrayBufferView + +export const createWssChannel = async (options: ChannelOptions): Promise => { + const { username, handleMessage } = options + + const rootDid = await await did.root(username).catch(() => null) + if (!rootDid) { + throw new LinkingError(`Failed to lookup DID for ${username}`) + } + + const apiEndpoint = setup.getApiEndpoint() + const endpoint = apiEndpoint.replace(/^https?:\/\//, "wss://") + const topic = `deviceLink#${rootDid}` + console.log("Opening channel", topic) + + const socket: Maybe = new WebSocket(`${endpoint}/user/link/${rootDid}`) + await waitForOpenConnection(socket) + socket.onmessage = handleMessage + + const send = publishOnWssChannel(socket) + const close = closeWssChannel(socket) + + return { + send, + close + } +} + +const waitForOpenConnection = async (socket: WebSocket): Promise => { + return new Promise((resolve, reject) => { + socket.onopen = () => { + resolve() + } + socket.onerror = () => { + reject("Websocket channel could not be opened") + } + }) +} + +export const closeWssChannel = (socket: Maybe): () => void => { + return function () { + if (socket) { + socket.close(1000) + } + } +} + +export const publishOnWssChannel = (socket: WebSocket): (data: ChannelData) => void => { + return function (data: ChannelData) { + const binary = typeof data === "string" + ? new TextEncoder().encode(data).buffer + : data + + socket?.send(binary) + } +} \ No newline at end of file diff --git a/src/auth/implementation/types.ts b/src/auth/implementation/types.ts index b6ab32d4f..e89fe9280 100644 --- a/src/auth/implementation/types.ts +++ b/src/auth/implementation/types.ts @@ -1,10 +1,15 @@ import { InitOptions } from "../../init/types.js" import { State } from "../state.js" +import type { Channel, ChannelOptions } from "../../auth/channel" export type Implementation = { init: (options: InitOptions) => Promise register: (options: { email: string; username: string }) => Promise<{ success: boolean }> isUsernameValid: (username: string) => Promise isUsernameAvailable: (username: string) => Promise + createChannel: (options: ChannelOptions) => Promise + checkCapability: (username: string) => Promise + delegateAccount: (username: string, audience: string) => Promise> + linkDevice: (data: Record) => Promise } diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 000000000..d25d35512 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,16 @@ +import { impl } from "./implementation.js" + +export const register = async (options: { username: string; email: string }): Promise<{ success: boolean }> => { + return impl.register(options) +} + +export const isUsernameValid = async (username: string): Promise => { + return impl.isUsernameValid(username) +} + +export const isUsernameAvailable = async (username: string): Promise => { + return impl.isUsernameAvailable(username) +} + +export { AccountLinkingProducer, createProducer } from "./linking/producer.js" +export { AccountLinkingConsumer, createConsumer } from "./linking/consumer.js" \ No newline at end of file diff --git a/src/auth/internal.ts b/src/auth/internal.ts index ef2f61764..4654f4d77 100644 --- a/src/auth/internal.ts +++ b/src/auth/internal.ts @@ -3,11 +3,13 @@ import { InitOptions } from "../init/types.js" import { State } from "./state.js" +import type { Channel, ChannelOptions } from "./channel" + export const init = (options: InitOptions): Promise => { return authLobby.init(options) } -export const register = (options: { username: string; email: string }): Promise<{success: boolean}> => { +export const register = (options: { username: string; email: string }): Promise<{ success: boolean }> => { return authLobby.register(options) } @@ -18,3 +20,20 @@ export const isUsernameValid = (username: string): Promise => { export const isUsernameAvailable = (username: string): Promise => { return authLobby.isUsernameAvailable(username) } + + +export const createChannel = (options: ChannelOptions): Promise => { + return authLobby.createChannel(options) +} + +export const checkCapability = async (username: string): Promise => { + return authLobby.checkCapability(username) +} + +export const delegateAccount = (username: string, audience: string): Promise> => { + return authLobby.delegateAccount(username, audience) +} + +export const linkDevice = (data: Record): Promise => { + return authLobby.linkDevice(data) +} diff --git a/src/auth/linking.ts b/src/auth/linking.ts new file mode 100644 index 000000000..8e3df4786 --- /dev/null +++ b/src/auth/linking.ts @@ -0,0 +1,66 @@ +import type { Result } from "../common/index.js" + +import * as debug from "../common/debug.js" + + +export enum LinkingStep { + Broadcast = "BROADCAST", + Negotiation = "NEGOTIATION", + Delegation = "DELEGATION" +} + +export class LinkingError extends Error { + constructor(message: string) { + super(message) + this.name = "LinkingError" + } +} + +export class LinkingWarning extends Error { + constructor(message: string) { + super(message) + this.name = "LinkingWarning" + } +} + +export const handleLinkingError = (error: LinkingError | LinkingWarning): void => { + switch (error.name) { + case "LinkingWarning": + debug.warn(error.message) + break + + case "LinkingError": + throw error + + default: + throw error + } +} + +export const tryParseMessage = ( + data: string, + typeGuard: (message: unknown) => message is T, + context: { participant: string; callSite: string } +): Result => { + try { + const message = JSON.parse(data) + + if (typeGuard(message)) { + return { + ok: true, + value: message + } + } else { + return { + ok: false, + error: new LinkingWarning(`${context.participant} received an unexpected message in ${context.callSite}: ${data}. Ignoring message.`) + } + } + + } catch { + return { + ok: false, + error: new LinkingWarning(`${context.participant} received a message in ${context.callSite} that it could not parse: ${data}. Ignoring message.`) + } + } +} \ No newline at end of file diff --git a/src/auth/linking/consumer.test.ts b/src/auth/linking/consumer.test.ts new file mode 100644 index 000000000..c99627dfd --- /dev/null +++ b/src/auth/linking/consumer.test.ts @@ -0,0 +1,442 @@ +import expect from "expect" +import aes from "keystore-idb/lib/aes/index.js" +import config from "keystore-idb/lib/config.js" +import rsa from "keystore-idb/lib/rsa/index.js" +import { CharSize, KeyUse, SymmAlg } from "keystore-idb/lib/types.js" +import utils from "keystore-idb/lib/utils.js" + +import * as did from "../../../src/did/index.js" +import * as consumer from "./consumer.js" +import * as ucan from "../../ucan/index.js" +import { LOCAL_IMPLEMENTATION } from "../local.js" +import { setImplementations } from "../../setup.js" + +describe("generate temporary exchange key", async () => { + it("returns a temporary RSA key pair and DID", async () => { + const { temporaryRsaPair, temporaryDID } = await consumer.generateTemporaryExchangeKey() + + expect(temporaryRsaPair).toBeDefined() + expect(temporaryRsaPair).not.toBeNull() + expect(temporaryDID).toBeDefined() + expect(temporaryDID).not.toBeNull() + }) + + it("returns a DID that matches the temporary RSA public key", async () => { + const { temporaryRsaPair, temporaryDID } = await consumer.generateTemporaryExchangeKey() + const temporaryPublicKey = await rsa.getPublicKey(temporaryRsaPair) + const didPublicKey = did.didToPublicKey(temporaryDID).publicKey + + expect(didPublicKey).toEqual(temporaryPublicKey) + }) +}) + +describe("handle session key", async () => { + let temporaryRsaPair: CryptoKeyPair + let temporaryDID: string + let sessionKey: CryptoKey + let exportedSessionKey: string + let encryptedSessionKey: ArrayBuffer + let iv: ArrayBuffer + + beforeEach(async () => { + const cfg = config.normalize() + const { rsaSize, hashAlg } = cfg + temporaryRsaPair = await rsa.makeKeypair(rsaSize, hashAlg, KeyUse.Exchange) + sessionKey = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + exportedSessionKey = await aes.exportKey(sessionKey) + iv = utils.randomBuf(16) + + const exportedPubKey = await rsa.getPublicKey(temporaryRsaPair) + temporaryDID = did.publicKeyToDid(exportedPubKey, did.KeyType.RSA) + + const rawSessionKey = utils.arrBufToStr(utils.base64ToArrBuf(exportedSessionKey), CharSize.B16) + if (!temporaryRsaPair.publicKey) throw new Error("Temporary RSA public key missing") + encryptedSessionKey = await rsa.encrypt(rawSessionKey, temporaryRsaPair.publicKey) + }) + + it("returns a session key after validating a closed UCAN", async () => { + const closedUcan = await ucan.build({ + issuer: await did.ucan(), + audience: temporaryDID, + lifetimeInSeconds: 60 * 5, + facts: [{ sessionKey: exportedSessionKey }], + potency: null + }) + const msg = await aes.encrypt(ucan.encode(closedUcan), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKey) + }) + if (!temporaryRsaPair.privateKey) throw new Error("Temporary RSA private key missing") + + const sessionKeyResult = await consumer.handleSessionKey(temporaryRsaPair.privateKey, message) + + let val + if (sessionKeyResult.ok) { val = sessionKeyResult.value } + + expect(sessionKeyResult.ok).toBe(true) + expect(val).toEqual(sessionKey) + }) + + it("returns a warning when the message received has the wrong shape", async () => { + const closedUcan = await ucan.build({ + issuer: await did.ucan(), + audience: temporaryDID, + lifetimeInSeconds: 60 * 5, + facts: [{ sessionKey: exportedSessionKey }], + potency: null + }) + const msg = await aes.encrypt(ucan.encode(closedUcan), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const message = JSON.stringify({ + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKey) + }) + if (!temporaryRsaPair.privateKey) throw new Error("Temporary RSA private key missing") + + const sessionKeyResult = await consumer.handleSessionKey(temporaryRsaPair.privateKey, message) + + let err + if (sessionKeyResult.ok === false) { err = sessionKeyResult.error } + + expect(sessionKeyResult.ok).toBe(false) + expect(err?.name === "LinkingWarning").toBe(true) + }) + + it("returns a warning when it receives a session key it cannot decrypt with its temporary private key", async () => { + const cfg = config.normalize() + const { rsaSize, hashAlg } = cfg + const temporaryRsaPairNoise = await rsa.makeKeypair(rsaSize, hashAlg, KeyUse.Exchange) + const rawSessionKeyNoise = utils.arrBufToStr(utils.base64ToArrBuf(exportedSessionKey), CharSize.B16) + + if (!temporaryRsaPairNoise.publicKey) throw new Error("Temporary RSA public key missing") + const encryptedSessionKeyNoise = await rsa.encrypt(rawSessionKeyNoise, temporaryRsaPairNoise.publicKey) + + const closedUcan = await ucan.build({ + issuer: await did.ucan(), + audience: temporaryDID, + lifetimeInSeconds: 60 * 5, + facts: [{ sessionKey: exportedSessionKey }], + potency: null + }) + const msg = await aes.encrypt(ucan.encode(closedUcan), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const message = JSON.stringify({ + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKeyNoise) // session key encrypted with noise + }) + if (!temporaryRsaPair.privateKey) throw new Error("Temporary RSA private key missing") + + const sessionKeyResult = await consumer.handleSessionKey(temporaryRsaPair.privateKey, message) + + let err + if (sessionKeyResult.ok === false) { err = sessionKeyResult.error } + + expect(sessionKeyResult.ok).toBe(false) + expect(err?.name === "LinkingWarning").toBe(true) + }) + + it("returns an error when closed UCAN cannot be decrypted with the provided session key", async () => { + const mismatchedSessionKey = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + const closedUcan = await ucan.build({ + issuer: await did.ucan(), + audience: temporaryDID, + lifetimeInSeconds: 60 * 5, + facts: [{ sessionKey: exportedSessionKey }], + potency: null + }) + const msg = await aes.encrypt(ucan.encode(closedUcan), mismatchedSessionKey, { iv, alg: SymmAlg.AES_GCM }) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKey) + }) + if (!temporaryRsaPair.privateKey) throw new Error("Temporary RSA private key missing") + + const sessionKeyResult = await consumer.handleSessionKey(temporaryRsaPair.privateKey, message) + + let err + if (sessionKeyResult.ok === false) { err = sessionKeyResult.error } + + expect(sessionKeyResult.ok).toBe(false) + expect(err?.name === "LinkingError").toBe(true) + }) + + it("returns an error when the closed UCAN is invalid", async () => { + const closedUcan = await ucan.build({ + issuer: "invalidIssuer", // Invalid issuer DID + audience: temporaryDID, + lifetimeInSeconds: 60 * 5, + facts: [{ sessionKey: exportedSessionKey }], + potency: null + }) + const msg = await aes.encrypt(ucan.encode(closedUcan), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKey) + }) + if (!temporaryRsaPair.privateKey) throw new Error("Temporary RSA private key missing") + + const sessionKeyResult = await consumer.handleSessionKey(temporaryRsaPair.privateKey, message) + + let err + if (sessionKeyResult.ok === false) { err = sessionKeyResult.error } + + expect(sessionKeyResult.ok).toBe(false) + expect(err?.name === "LinkingError").toBe(true) + }) + + it("returns an error if the closed UCAN has potency", async () => { + const closedUcan = await ucan.build({ + issuer: await did.ucan(), + audience: temporaryDID, + lifetimeInSeconds: 60 * 5, + facts: [{ sessionKey: exportedSessionKey }], + potency: "SUPER_USER" // closed UCAN should have null potency + }) + const msg = await aes.encrypt(ucan.encode(closedUcan), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKey) + }) + if (!temporaryRsaPair.privateKey) throw new Error("Temporary RSA private key missing") + + const sessionKeyResult = await consumer.handleSessionKey(temporaryRsaPair.privateKey, message) + + let err + if (sessionKeyResult.ok === false) { err = sessionKeyResult.error } + + expect(sessionKeyResult.ok).toBe(false) + expect(err?.name === "LinkingError").toBe(true) + }) + + it("returns an error if session key missing in closed UCAN", async () => { + const closedUcan = await ucan.build({ + issuer: await did.ucan(), + audience: temporaryDID, + lifetimeInSeconds: 60 * 5, + facts: [], // session key missing in facts + potency: null + }) + const msg = await aes.encrypt(ucan.encode(closedUcan), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKey) + }) + if (!temporaryRsaPair.privateKey) throw new Error("Temporary RSA private key missing") + + const sessionKeyResult = await consumer.handleSessionKey(temporaryRsaPair.privateKey, message) + + let err + if (sessionKeyResult.ok === false) { err = sessionKeyResult.error } + + expect(sessionKeyResult.ok).toBe(false) + expect(err?.name === "LinkingError").toBe(true) + }) + + it("returns an error if session key in closed UCAN does not match session key", async () => { + const closedUcan = await ucan.build({ + issuer: await did.ucan(), + audience: temporaryDID, + lifetimeInSeconds: 60 * 5, + facts: [{ sessionKey: "mismatchedSessionKey" }], // does not match session key + potency: null + }) + const msg = await aes.encrypt(ucan.encode(closedUcan), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKey) + }) + if (!temporaryRsaPair.privateKey) throw new Error("Temporary RSA private key missing") + + const sessionKeyResult = await consumer.handleSessionKey(temporaryRsaPair.privateKey, message) + + let err + if (sessionKeyResult.ok === false) { err = sessionKeyResult.error } + + expect(sessionKeyResult.ok).toBe(false) + expect(err?.name === "LinkingError").toBe(true) + }) +}) + +describe("generate a user challenge", async () => { + let sessionKey: CryptoKey + + beforeEach(async () => { + sessionKey = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + }) + + it("generates a pin and challenge message", async () => { + const { pin, challenge } = await consumer.generateUserChallenge(sessionKey) + + expect(pin).toBeDefined() + expect(pin).not.toBeNull() + expect(challenge).toBeDefined() + expect(challenge).not.toBeNull() + }) + + it("challenge message can be decrypted", async () => { + const { challenge } = await consumer.generateUserChallenge(sessionKey) + const { iv, msg } = JSON.parse(challenge) + + expect(async () => await aes.decrypt(msg, sessionKey, { alg: SymmAlg.AES_GCM, iv })).not.toThrow() + }) + + it("challenge message pin matches original pin", async () => { + const { pin, challenge } = await consumer.generateUserChallenge(sessionKey) + const { iv, msg } = JSON.parse(challenge) + const json = await aes.decrypt(msg, sessionKey, { alg: SymmAlg.AES_GCM, iv }) + const message = JSON.parse(json) + + const originalPin = Array.from(pin) + const messagePin = Object.values(message.pin) as number[] + + expect(messagePin).toEqual(originalPin) + }) +}) + +describe("link device", async () => { + let sessionKey: CryptoKey + let deviceLinked: boolean + const username = "snakecase" // username is set in storage, not important for these tests + + const linkDevice = async (data: Record): Promise => { + if (data.link === true) { + deviceLinked = true + } + } + + before(async () => { + setImplementations({ + auth: { + ...LOCAL_IMPLEMENTATION.auth, + linkDevice + } + }) + }) + + beforeEach(async () => { + sessionKey = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + deviceLinked = false + }) + + it("links a device on approval", async () => { + const iv = utils.randomBuf(16) + const msg = await aes.encrypt( + JSON.stringify({ linkStatus: "APPROVED", delegation: { link: true } }), + sessionKey, + { iv, alg: SymmAlg.AES_GCM } + ) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + }) + + const linkMessage = await consumer.linkDevice(sessionKey, username, message) + + let val = null + if (linkMessage.ok) { val = linkMessage.value } + + expect(val?.approved).toEqual(true) + expect(deviceLinked).toEqual(true) + }) + + it("does not link on rejection", async () => { + const iv = utils.randomBuf(16) + const msg = await aes.encrypt( + JSON.stringify({ linkStatus: "DENIED", delegation: { link: false } }), + sessionKey, + { iv, alg: SymmAlg.AES_GCM } + ) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + }) + + const linkMessage = await consumer.linkDevice(sessionKey, username, message) + + let val = null + if (linkMessage.ok) { val = linkMessage.value } + + expect(val?.approved).toEqual(false) + expect(deviceLinked).toEqual(false) + }) + + it("returns a warning when the message received has the wrong shape", async () => { + const iv = utils.randomBuf(16) + const msg = await aes.encrypt( + JSON.stringify({ linkStatus: "DENIED", delegation: { link: false } }), + sessionKey, + { iv, alg: SymmAlg.AES_GCM } + ) + const message = JSON.stringify({ + msg, // iv missing + }) + + const linkMessage = await consumer.linkDevice(sessionKey, username, message) + + let err + if (linkMessage.ok === false) { err = linkMessage.error } + + expect(linkMessage.ok).toBe(false) + expect(err?.name === "LinkingWarning").toBe(true) + }) + + it("returns a warning when it receives a temporary DID", async () => { + const temporaryDID = await did.ucan() + + const userChallengeResult = await consumer.linkDevice(sessionKey, username, temporaryDID) + + let err = null + if (userChallengeResult.ok === false) { err = userChallengeResult.error } + + expect(userChallengeResult.ok).toBe(false) + expect(err?.name === "LinkingWarning").toBe(true) + }) + + it("returns a warning when it receives a message it cannot decrypt", async () => { + const sessionKeyNoise = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + const iv = utils.randomBuf(16) + const msg = await aes.encrypt( + JSON.stringify({ linkStatus: "DENIED", delegation: { link: false } }), + sessionKeyNoise, + { iv, alg: SymmAlg.AES_GCM } + ) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg + }) + + const linkMessage = await consumer.linkDevice(sessionKey, username, message) + + let err + if (linkMessage.ok === false) { err = linkMessage.error } + + expect(linkMessage.ok).toBe(false) + expect(err?.name === "LinkingWarning").toBe(true) + }) + + it("returns an error when it receives an invalid linking status message", async () => { + const iv = utils.randomBuf(16) + const msg = await aes.encrypt( + JSON.stringify({ linkStatus: "INVALID", delegation: { link: true } }), + sessionKey, + { iv, alg: SymmAlg.AES_GCM } + ) + const message = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg + }) + + const linkMessage = await consumer.linkDevice(sessionKey, username, message) + + let err + if (linkMessage.ok === false) { err = linkMessage.error } + + expect(linkMessage.ok).toBe(false) + expect(err?.name === "LinkingError").toBe(true) + }) +}) \ No newline at end of file diff --git a/src/auth/linking/consumer.ts b/src/auth/linking/consumer.ts new file mode 100644 index 000000000..13185c4a4 --- /dev/null +++ b/src/auth/linking/consumer.ts @@ -0,0 +1,292 @@ +import aes from "keystore-idb/lib/aes/index.js" +import config from "keystore-idb/lib/config.js" +import rsa from "keystore-idb/lib/rsa/index.js" +import utils from "keystore-idb/lib/utils.js" +import { KeyUse, SymmAlg } from "keystore-idb/lib/types.js" + +import * as did from "../../did/index.js" +import * as storage from "../../storage/index.js" +import * as ucan from "../../ucan/index.js" +import { impl as auth } from "../implementation.js" +import { EventEmitter, EventListener } from "../../common/event-emitter.js" +import { USERNAME_STORAGE_KEY } from "../../common/index.js" +import { LinkingError, LinkingStep, LinkingWarning, handleLinkingError, tryParseMessage } from "../linking.js" + +import type { Maybe, Result } from "../../common/index.js" + + +export type AccountLinkingConsumer = { + on: (eventName: K, listener: EventListener) => void + cancel: () => void +} +export interface ConsumerEventMap { + "challenge": { pin: number[] } + "link": { approved: boolean; username: string } + "done": undefined +} + +type LinkingState = { + username: Maybe + sessionKey: Maybe + temporaryRsaPair: Maybe + step: Maybe +} + +/** + * Create an account linking consumer + * + * @param options consumer options + * @param options.username username of the account + * @returns an account linking event emitter and cancel function + */ +export const createConsumer = async (options: { username: string }): Promise => { + const { username } = options + let eventEmitter: Maybe> = new EventEmitter() + const ls: LinkingState = { + username, + sessionKey: null, + temporaryRsaPair: null, + step: LinkingStep.Broadcast + } + + const handleMessage = async (event: MessageEvent): Promise => { + const { data } = event + const message = data.arrayBuffer ? new TextDecoder().decode(await data.arrayBuffer()) : data + + if (ls.step === LinkingStep.Broadcast) { + handleLinkingError(new LinkingWarning("Consumer is not ready to start linking")) + } else if (ls.step === LinkingStep.Negotiation) { + if (ls.sessionKey) { + handleLinkingError(new LinkingWarning("Consumer already received a session key")) + } else if (!ls.temporaryRsaPair || !ls.temporaryRsaPair.privateKey) { + handleLinkingError(new LinkingError("Consumer missing RSA key pair when handling session key message")) + } else { + const sessionKeyResult = await handleSessionKey(ls.temporaryRsaPair.privateKey, message) + + if (sessionKeyResult.ok) { + ls.sessionKey = sessionKeyResult.value + + const { pin, challenge } = await generateUserChallenge(ls.sessionKey) + channel.send(challenge) + eventEmitter?.emit("challenge", { pin: Array.from(pin) }) + ls.step = LinkingStep.Delegation + } else { + handleLinkingError(sessionKeyResult.error) + } + } + } else if (ls.step === LinkingStep.Delegation){ + if (!ls.sessionKey) { + handleLinkingError(new LinkingError("Consumer was missing session key when linking device")) + } else if (!ls.username) { + handleLinkingError(new LinkingError("Consumer was missing username when linking device")) + } else { + const linkingResult = await linkDevice(ls.sessionKey, ls.username, message) + + if (linkingResult.ok) { + const { approved } = linkingResult.value + eventEmitter?.emit("link", { approved, username: ls.username }) + await done() + } else { + handleLinkingError(linkingResult.error) + } + } + } + } + + const done = async () => { + eventEmitter?.emit("done", undefined) + eventEmitter = null + channel.close() + clearInterval(rsaExchangeInterval) + } + + const channel = await auth.createChannel({ username, handleMessage }) + + const rsaExchangeInterval = setInterval(async () => { + if (!ls.sessionKey) { + const { temporaryRsaPair, temporaryDID } = await generateTemporaryExchangeKey() + ls.temporaryRsaPair = temporaryRsaPair + ls.step = LinkingStep.Negotiation + + await channel.send(temporaryDID) + } else { + clearInterval(rsaExchangeInterval) + } + }, 2000) + + return { + on: (...args) => eventEmitter?.on(...args), + cancel: done + } +} + + +// 🔗 Device Linking Steps + +/** + * BROADCAST + * + * Generate a temporary RSA keypair and extract a temporary DID from it. + * The temporary DID will be broadcast on the channel to start the linking process. + * + * @returns temporary RSA key pair and temporary DID + */ +export const generateTemporaryExchangeKey = async (): Promise<{ temporaryRsaPair: CryptoKeyPair; temporaryDID: string }> => { + const cfg = config.normalize() + + const { rsaSize, hashAlg } = cfg + const temporaryRsaPair = await rsa.makeKeypair(rsaSize, hashAlg, KeyUse.Exchange) + const pubKey = await rsa.getPublicKey(temporaryRsaPair) + const temporaryDID = did.publicKeyToDid(pubKey, did.KeyType.RSA) + return { temporaryRsaPair, temporaryDID } +} + +/** + * NEGOTIATION + * + * Decrypt the session key and check the closed UCAN for capability. + * The session key is encrypted with the temporary RSA keypair. + * The closed UCAN is encrypted with the session key. + * + * @param temporaryRsaPrivateKey + * @param data + * @returns AES session key + */ +export const handleSessionKey = async (temporaryRsaPrivateKey: CryptoKey, data: string): Promise> => { + const typeGuard = (message: any): message is { iv: ArrayBuffer; msg: string; sessionKey: string } => { + return "iv" in message && "msg" in message && "sessionKey" in message + } + + const parseResult = tryParseMessage(data, typeGuard, { participant: "Consumer", callSite: "handleSessionKey" }) + + if (parseResult.ok) { + const { iv, msg, sessionKey: encodedSessionKey } = parseResult.value + + let sessionKey, rawSessionKey + try { + const encryptedSessionKey = utils.base64ToArrBuf(encodedSessionKey) + rawSessionKey = await rsa.decrypt(encryptedSessionKey, temporaryRsaPrivateKey) + sessionKey = await aes.importKey(utils.arrBufToBase64(rawSessionKey), { alg: SymmAlg.AES_GCM, length: 256 }) + } catch { + return { ok: false, error: new LinkingWarning(`Consumer received a session key in handleSessionKey that it could not decrypt: ${data}. Ignoring message`) } + } + + let encodedUcan = null + try { + encodedUcan = await aes.decrypt( + msg, + sessionKey, + { + alg: SymmAlg.AES_GCM, + iv: iv + } + ) + } catch { + return { ok: false, error: new LinkingError("Consumer could not decrypt closed UCAN with provided session key.") } + } + + const decodedUcan = ucan.decode(encodedUcan) + + if (await ucan.isValid(decodedUcan) === false) { + return { ok: false, error: new LinkingError("Consumer received an invalid closed UCAN") } + } + + if (decodedUcan.payload.ptc) { + return { ok: false, error: new LinkingError("Consumer received a closed UCAN with potency. Closed UCAN must not have potency.") } + } + + const sessionKeyFromFact = decodedUcan.payload.fct[0] && decodedUcan.payload.fct[0].sessionKey + if (!sessionKeyFromFact) { + return { ok: false, error: new LinkingError("Consumer received a closed UCAN that was missing a session key in facts.") } + } + + const sessionKeyWeAlreadyGot = utils.arrBufToBase64(rawSessionKey) + if (sessionKeyFromFact !== sessionKeyWeAlreadyGot) { + return { ok: false, error: new LinkingError("Consumer received a closed UCAN session key does not match the session key") } + } + + return { ok: true, value: sessionKey } + } else { + return parseResult + } +} + + +/** + * NEGOTIATION + * + * Generate pin and challenge message for verification by the producer. + * + * @param sessionKey + * @returns pin and challenge message + */ +export const generateUserChallenge = async (sessionKey: CryptoKey): Promise<{ pin: Uint8Array; challenge: string }> => { + const pin = new Uint8Array(utils.randomBuf(6)).map(n => { + return n % 10 + }) + + const iv = utils.randomBuf(16) + const msg = await aes.encrypt(JSON.stringify({ + did: await did.ucan(), + pin + }), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + + const challenge = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg + }) + + return { pin, challenge } +} + +/** + * DELEGATION + * + * Decrypt the delegated credentials and forward to the dependency injected linkDevice function, + * or report that delegation was declined. + * + * @param sessionKey + * @param username + * @param data + * @returns linking result + */ +export const linkDevice = async (sessionKey: CryptoKey, username: string, data: string): Promise> => { + const typeGuard = (message: any): message is { iv: ArrayBuffer; msg: string } => { + return "iv" in message && "msg" in message + } + + const parseResult = tryParseMessage(data, typeGuard, { participant: "Consumer", callSite: "linkDevice" }) + + if (parseResult.ok) { + const { iv, msg } = parseResult.value + + let message = null + try { + message = await aes.decrypt( + msg, + sessionKey, + { + alg: SymmAlg.AES_GCM, + iv: iv + } + ) + } catch { + return { ok: false, error: new LinkingWarning("Consumer ignoring message that could not be decrypted in linkDevice.") } + } + + const response = JSON.parse(message) + + if (response.linkStatus === "APPROVED") { + await storage.setItem(USERNAME_STORAGE_KEY, username) + await auth.linkDevice(response.delegation) + + return { ok: true, value: { approved: true } } + } else if (response.linkStatus === "DENIED") { + return { ok: true, value: { approved: false } } + } else { + return { ok: false, error: new LinkingError("Consumer received an invalid link device message received from producer.") } + } + } else { + return parseResult + } +} \ No newline at end of file diff --git a/src/auth/linking/producer.test.ts b/src/auth/linking/producer.test.ts new file mode 100644 index 000000000..54f012368 --- /dev/null +++ b/src/auth/linking/producer.test.ts @@ -0,0 +1,258 @@ +import expect from "expect" +import * as fc from "fast-check" +import aes from "keystore-idb/lib/aes/index.js" +import { SymmAlg } from "keystore-idb/lib/types.js" +import utils from "keystore-idb/lib/utils.js" + +import * as did from "../../../src/did/index.js" +import * as producer from "./producer.js" +import * as ucan from "../../ucan/index.js" +import { LOCAL_IMPLEMENTATION } from "../local.js" +import { setImplementations } from "../../setup.js" + +describe("generate session key", async () => { + let DID: string + + beforeEach(async () => { + DID = await did.ucan() + }) + + it("generates a session key and a session key message", async () => { + const { sessionKey, sessionKeyMessage } = await producer.generateSessionKey(DID) + + expect(sessionKey).toBeDefined() + expect(sessionKey).not.toBeNull() + expect(sessionKeyMessage).toBeDefined() + expect(sessionKeyMessage).not.toBeNull() + }) + + it("generates a session key message that can be decrypted with the session key", async () => { + const { sessionKey, sessionKeyMessage } = await producer.generateSessionKey(DID) + const { iv, msg } = JSON.parse(sessionKeyMessage) + + expect(async () => { await aes.decrypt(msg, sessionKey, { alg: SymmAlg.AES_GCM, iv: iv }) }).not.toThrow() + }) + + it("generates a valid closed UCAN", async () => { + const { sessionKey, sessionKeyMessage } = await producer.generateSessionKey(DID) + const { iv, msg } = JSON.parse(sessionKeyMessage) + const encodedUcan = await aes.decrypt(msg, sessionKey, { alg: SymmAlg.AES_GCM, iv: iv }) + const decodedUcan = ucan.decode(encodedUcan) + + expect(await ucan.isValid(decodedUcan)).toBe(true) + }) + + it("generates a closed UCAN without any potency", async () => { + const { sessionKey, sessionKeyMessage } = await producer.generateSessionKey(DID) + const { iv, msg } = JSON.parse(sessionKeyMessage) + const encodedUcan = await aes.decrypt(msg, sessionKey, { alg: SymmAlg.AES_GCM, iv: iv }) + const decodedUcan = ucan.decode(encodedUcan) + + expect(await decodedUcan.payload.ptc).toBe(null) + }) + + it("generates a closed UCAN with the session key in its facts", async () => { + const { sessionKey, sessionKeyMessage } = await producer.generateSessionKey(DID) + const { iv, msg } = JSON.parse(sessionKeyMessage) + const encodedUcan = await aes.decrypt(msg, sessionKey, { alg: SymmAlg.AES_GCM, iv: iv }) + const decodedUcan = ucan.decode(encodedUcan) + const sessionKeyFromFact = decodedUcan.payload.fct[0] && decodedUcan.payload.fct[0].sessionKey + const exportedSessionKey = await aes.exportKey(sessionKey) + + expect(sessionKeyFromFact).not.toBe(null) + expect(exportedSessionKey === sessionKeyFromFact).toBe(true) + }) +}) + +describe("handle user challenge", async () => { + let DID: string + let sessionKey: CryptoKey + let sessionKeyNoise: CryptoKey + + beforeEach(async () => { + DID = await did.ucan() + sessionKey = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + sessionKeyNoise = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + }) + + it("challenge message pin and audience match original pin and audience", async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + pin: fc.uint8Array({ min: 0, max: 9, minLength: 6, maxLength: 6 }), + iv: fc.uint8Array({ minLength: 16, maxLength: 16 }).map(arr => arr.buffer) + }), async ({ pin, iv }) => { + const msg = await aes.encrypt(JSON.stringify({ did: DID, pin }), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const challenge = JSON.stringify({ iv: utils.arrBufToBase64(iv), msg }) + const userChallengeResult = await producer.handleUserChallenge(sessionKey, challenge) + + let val = null + if (userChallengeResult.ok) { val = userChallengeResult.value } + + expect(userChallengeResult.ok).toBe(true) + expect(val?.pin).toEqual(Array.from(pin)) + expect(val?.audience).toEqual(DID) + }) + ) + }) + + it("returns a warning when it receives a temporary DID", async () => { + const temporaryDID = await did.ucan() + + // A producer may be be partway through linking when another temporary DID arrives. This event will + // trigger a warning but will otherwise ignore the message. + const userChallengeResult = await producer.handleUserChallenge(sessionKey, temporaryDID) + + let err = null + if (userChallengeResult.ok === false) { err = userChallengeResult.error } + + expect(userChallengeResult.ok).toBe(false) + expect(err?.name === "LinkingWarning").toBe(true) + }) + + it("returns a warning when the message received has the wrong shape", async () => { + const pin = [0, 0, 0, 0, 0, 0] + const iv = utils.randomBuf(16) + const msg = await aes.encrypt(JSON.stringify({ did: DID, pin }), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const challenge = JSON.stringify({ msg }) // initialization vector missing + const userChallengeResult = await producer.handleUserChallenge(sessionKey, challenge) + + let err = null + if (userChallengeResult.ok === false) { err = userChallengeResult.error } + + expect(userChallengeResult.ok).toBe(false) + expect(err?.name === "LinkingWarning").toBe(true) + }) + + it("returns an error when pin is missing", async () => { + const iv = utils.randomBuf(16) + const msg = await aes.encrypt(JSON.stringify({ did: DID }), sessionKey, { iv, alg: SymmAlg.AES_GCM }) // pin missing + const challenge = JSON.stringify({ iv: utils.arrBufToBase64(iv), msg }) + const userChallengeResult = await producer.handleUserChallenge(sessionKey, challenge) + + let err = null + if (userChallengeResult.ok === false) { err = userChallengeResult.error } + + expect(userChallengeResult.ok).toBe(false) + expect(err?.name === "LinkingError").toBe(true) + }) + + it("returns an error when audience DID is missing", async () => { + const pin = [0, 0, 0, 0, 0, 0] + const iv = utils.randomBuf(16) + const msg = await aes.encrypt(JSON.stringify({ pin }), sessionKey, { iv, alg: SymmAlg.AES_GCM }) // DID missing + const challenge = JSON.stringify({ iv: utils.arrBufToBase64(iv), msg }) + const userChallengeResult = await producer.handleUserChallenge(sessionKey, challenge) + + let err = null + if (userChallengeResult.ok === false) { err = userChallengeResult.error } + + expect(userChallengeResult.ok).toBe(false) + expect(err?.name === "LinkingError").toBe(true) + }) + + it("ignores challenge messages it cannot decrypt", async () => { + const pin = [0, 0, 0, 0, 0, 0] + const iv = utils.randomBuf(16) + const msg = await aes.encrypt(JSON.stringify({ did: DID, pin }), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + const challenge = JSON.stringify({ iv: utils.arrBufToBase64(iv), msg }) + const userChallengeResult = await producer.handleUserChallenge(sessionKeyNoise, challenge) + + let err = null + if (userChallengeResult.ok === false) { err = userChallengeResult.error } + + expect(userChallengeResult.ok).toBe(false) + expect(err?.name === "LinkingWarning").toBe(true) + }) +}) + + +describe("delegate account", async () => { + let sessionKey: CryptoKey + let accountDelegated: boolean | null + let approvedMessage: boolean | null + const username = "snakecase" + const audience = "audie" + + const delegateAccount = async (username: string, audience: string): Promise> => { + return { username, audience } + } + + const finishDelegation = async (delegationMessage: string, approved: boolean): Promise => { + const { iv, msg } = JSON.parse(delegationMessage) + const message = await aes.decrypt(msg, sessionKey, { alg: SymmAlg.AES_GCM, iv: iv }) + const link = JSON.parse(message) + + approvedMessage = approved + + if (link.linkStatus === "APPROVED" && + link.delegation.username === username && + link.delegation.audience === audience) { + accountDelegated = true + } + } + + before(async () => { + setImplementations({ + auth: { + ...LOCAL_IMPLEMENTATION.auth, + delegateAccount + } + }) + }) + + beforeEach(async () => { + sessionKey = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + accountDelegated = null + approvedMessage = null + }) + + it("delegates an account", async () => { + await producer.delegateAccount(sessionKey, username, audience, finishDelegation) + + expect(accountDelegated).toBe(true) + }) + + it("calls finish delegation with an approved message", async () => { + await producer.delegateAccount(sessionKey, username, audience, finishDelegation) + + expect(approvedMessage).toBe(true) + }) +}) + + +describe("decline delegation", async () => { + let sessionKey: CryptoKey + let accountDelegated: boolean | null + let approvedMessage: boolean | null + + const finishDelegation = async (delegationMessage: string, approved: boolean): Promise => { + const { iv, msg } = JSON.parse(delegationMessage) + const message = await aes.decrypt(msg, sessionKey, { alg: SymmAlg.AES_GCM, iv: iv }) + const link = JSON.parse(message) + + approvedMessage = approved + + if (link.linkStatus === "DENIED") { + accountDelegated = false + } + } + + beforeEach(async () => { + sessionKey = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + accountDelegated = null + approvedMessage = null + }) + + it("declines to delegate an account", async () => { + await producer.declineDelegation(sessionKey, finishDelegation) + + expect(accountDelegated).toBe(false) + }) + + it("calls finish delegation with a declined message", async () => { + await producer.declineDelegation(sessionKey, finishDelegation) + + expect(approvedMessage).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/auth/linking/producer.ts b/src/auth/linking/producer.ts new file mode 100644 index 000000000..311309b81 --- /dev/null +++ b/src/auth/linking/producer.ts @@ -0,0 +1,288 @@ +import aes from "keystore-idb/lib/aes/index.js" +import rsa from "keystore-idb/lib/rsa/index.js" +import utils from "keystore-idb/lib/utils.js" +import { KeyUse, SymmAlg, HashAlg, CharSize } from "keystore-idb/lib/types.js" + +import * as did from "../../did/index.js" +import * as ucan from "../../ucan/index.js" +import { impl as auth } from "../implementation.js" +import { EventEmitter, EventListener } from "../../common/event-emitter.js" +import { LinkingError, LinkingStep, LinkingWarning, handleLinkingError, tryParseMessage } from "../linking.js" + +import type { Maybe, Result } from "../../common/index.js" + + +export type AccountLinkingProducer = { + on: (eventName: K, listener: EventListener) => void + cancel: () => void +} +export interface ProducerEventMap { + "challenge": { + pin: number[] + confirmPin: () => void + rejectPin: () => void + } + "link": { approved: boolean; username: string } + "done": undefined +} + + +type LinkingState = { + username: Maybe + sessionKey: Maybe + step: Maybe +} + +/** + * Create an account linking producer + * + * @param options producer options + * @param options.username username of the account + * @returns an account linking event emitter and cancel function + */ +export const createProducer = async (options: { username: string }): Promise => { + const { username } = options + const canDelegate = await auth.checkCapability(username) + + if (!canDelegate) { + throw new LinkingError(`Producer cannot delegate for username ${username}`) + } + + let eventEmitter: Maybe> = new EventEmitter() + const ls: LinkingState = { + username, + sessionKey: null, + step: LinkingStep.Broadcast + } + + const handleMessage = async (event: MessageEvent): Promise => { + const { data } = event + const message = data.arrayBuffer ? new TextDecoder().decode(await data.arrayBuffer()) : data + + if (ls.step === LinkingStep.Broadcast) { + const { sessionKey, sessionKeyMessage } = await generateSessionKey(message) + ls.sessionKey = sessionKey + ls.step = LinkingStep.Negotiation + channel.send(sessionKeyMessage) + } else if (ls.step === LinkingStep.Negotiation) { + if (ls.sessionKey) { + const userChallengeResult = await handleUserChallenge(ls.sessionKey, message) + ls.step = LinkingStep.Delegation + + if (userChallengeResult.ok) { + const { pin, audience } = userChallengeResult.value + + const challengeOnce = () => { + let called = false + + return { + confirmPin: async () => { + if (!called) { + called = true + + if (ls.sessionKey) { + await delegateAccount(ls.sessionKey, username, audience, finishDelegation) + } else { + handleLinkingError(new LinkingError("Producer missing session key when delegating account")) + } + } + }, + rejectPin: async () => { + if (!called) { + called = true + + if (ls.sessionKey) { + await declineDelegation(ls.sessionKey, finishDelegation) + } else { + handleLinkingError(new LinkingError("Producer missing session key when declining account delegation")) + } + } + } + } + } + const { confirmPin, rejectPin } = challengeOnce() + + eventEmitter?.emit("challenge", { pin, confirmPin, rejectPin }) + } else { + handleLinkingError(userChallengeResult.error) + } + + } else { + handleLinkingError(new LinkingError("Producer missing session key when handling user challenge")) + } + } else if (ls.step === LinkingStep.Delegation) { + handleLinkingError(new LinkingWarning("Producer received an unexpected message while delegating an account. The message will be ignored.")) + } + } + + const finishDelegation = async (delegationMessage: string, approved: boolean): Promise => { + await channel.send(delegationMessage) + + if (ls.username == null) return // or throw error? + + eventEmitter?.emit("link", { approved, username: ls.username }) + resetLinkingState() + } + + const resetLinkingState = () => { + ls.sessionKey = null + ls.step = LinkingStep.Broadcast + } + + const cancel = async () => { + eventEmitter?.emit("done", undefined) + eventEmitter = null + channel.close() + } + + const channel = await auth.createChannel({ username, handleMessage }) + + return { + on: (...args) => eventEmitter?.on(...args), + cancel + } +} + + +/** + * BROADCAST + * + * Generate a session key and prepare a session key message to send to the consumer. + * + * @param didThrowaway + * @returns session key and session key message + */ +export const generateSessionKey = async (didThrowaway: string): Promise<{ sessionKey: CryptoKey; sessionKeyMessage: string }> => { + const sessionKey = await aes.makeKey({ alg: SymmAlg.AES_GCM, length: 256 }) + + const exportedSessionKey = await aes.exportKey(sessionKey) + + const { publicKey } = did.didToPublicKey(didThrowaway) + const publicCryptoKey = await rsa.importPublicKey(publicKey, HashAlg.SHA_256, KeyUse.Exchange) + + // Note: rsa.encrypt expects a B16 string + const rawSessionKey = utils.arrBufToStr(utils.base64ToArrBuf(exportedSessionKey), CharSize.B16) + const encryptedSessionKey = await rsa.encrypt(rawSessionKey, publicCryptoKey) + + const u = await ucan.build({ + issuer: await did.ucan(), + audience: didThrowaway, + lifetimeInSeconds: 60 * 5, // 5 minutes + facts: [{ sessionKey: exportedSessionKey }], + potency: null + }) + + const iv = utils.randomBuf(16) + const msg = await aes.encrypt(ucan.encode(u), sessionKey, { iv, alg: SymmAlg.AES_GCM }) + + const sessionKeyMessage = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg, + sessionKey: utils.arrBufToBase64(encryptedSessionKey) + }) + + return { + sessionKey, + sessionKeyMessage + } +} + + +/** + * NEGOTIATION + * + * Decrypt the user challenge and the consumer audience DID. + * + * @param data + * @returns pin and audience + */ +export const handleUserChallenge = async (sessionKey: CryptoKey, data: string): Promise> => { + const typeGuard = (message: any): message is { iv: ArrayBuffer; msg: string } => { + return "iv" in message && "msg" in message + } + + const parseResult = tryParseMessage(data, typeGuard, { participant: "Producer", callSite: "handleUserChallenge" }) + + if (parseResult.ok) { + const { iv, msg } = parseResult.value + + let message = null + try { + message = await aes.decrypt(msg, sessionKey, { + alg: SymmAlg.AES_GCM, + iv + }) + } catch { + return { ok: false, error: new LinkingWarning("Ignoring message that could not be decrypted.") } + } + + const json = JSON.parse(message) + const pin = json.pin ? Object.values(json.pin) as number[] : null + const audience = json.did as string ?? null + + if (pin !== null && audience !== null) { + return { ok: true, value: { pin, audience } } + } else { + return { ok: false, error: new LinkingError(`Producer received invalid pin ${json.pin} or audience ${json.audience}`) } + } + } else { + return parseResult + } + +} + + +/** + * DELEGATION: Delegate account + * + * Request delegation from the dependency injected delegateAccount function. + * Prepare a delegation message to send to the consumer. + * + * @param sesionKey + * @param audience + * @param finishDelegation + */ +export const delegateAccount = async ( + sessionKey: CryptoKey, + username: string, + audience: string, + finishDelegation: (delegationMessage: string, approved: boolean) => Promise +): Promise => { + const delegation = await auth.delegateAccount(username, audience) + const message = JSON.stringify({ linkStatus: "APPROVED", delegation }) + + const iv = utils.randomBuf(16) + const msg = await aes.encrypt(message, sessionKey, { iv, alg: SymmAlg.AES_GCM }) + + const delegationMessage = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg + }) + + await finishDelegation(delegationMessage, true) +} + +/** + * DELEGATION: Decline delegation + * + * Prepare a delegation declined message to send to the consumer. + * + * @param sessionKey + * @param finishDelegation + */ +export const declineDelegation = async ( + sessionKey: CryptoKey, + finishDelegation: (delegationMessage: string, approved: boolean) => Promise +): Promise => { + const message = JSON.stringify({ linkStatus: "DENIED" }) + + const iv = utils.randomBuf(16) + const msg = await aes.encrypt(message, sessionKey, { iv, alg: SymmAlg.AES_GCM }) + + const delegationMessage = JSON.stringify({ + iv: utils.arrBufToBase64(iv), + msg + }) + + await finishDelegation(delegationMessage, false) +} \ No newline at end of file diff --git a/src/auth/lobby.ts b/src/auth/lobby.ts index 83dc3dbab..16162b762 100644 --- a/src/auth/lobby.ts +++ b/src/auth/lobby.ts @@ -1,5 +1,6 @@ import FileSystem from "../fs/index.js" +import type { Channel, ChannelOptions } from "./channel" import { Implementation } from "./implementation/types.js" import { InitOptions } from "../init/types.js" @@ -17,6 +18,9 @@ import * as pathing from "../path.js" import * as storage from "../storage/index.js" import * as ucan from "../ucan/internal.js" import * as user from "../lobby/username.js" +import * as token from "../ucan/token.js" +import * as channel from "./channel.js" + export const init = async (options: InitOptions): Promise => { @@ -100,6 +104,58 @@ export const isUsernameAvailable = async (username: string): Promise => return user.isUsernameAvailable(username) } +export const createChannel = (options: ChannelOptions): Promise => { + return channel.createWssChannel(options) +} + +export const checkCapability = async (username: string): Promise => { + const readKey = await storage.getItem("readKey") + if (!readKey) return false + + const didFromDNS = await did.root(username) + const maybeUcan: string | null = await storage.getItem("ucan") + + if (maybeUcan) { + const rootIssuerDid = token.rootIssuer(maybeUcan) + const decodedUcan = token.decode(maybeUcan) + const { ptc } = decodedUcan.payload + + return didFromDNS === rootIssuerDid && ptc === "SUPER_USER" + } else { + const rootDid = await did.write() + + return didFromDNS === rootDid + } +} + +export const delegateAccount = async (username: string, audience: string): Promise> => { + const readKey = await storage.getItem("readKey") + const proof = await storage.getItem("ucan") as string + + const u = await token.build({ + audience, + issuer: await did.write(), + lifetimeInSeconds: 60 * 60 * 24 * 30 * 12 * 1000, // 1000 years + potency: "SUPER_USER", + proof, + + // TODO: UCAN v0.7.0 + // proofs: [ await localforage.getItem("ucan") ] + }) + + return { readKey, token: token.encode(u) } +} + +export const linkDevice = async (data: Record): Promise => { + const { readKey, token: encodedToken } = data + const u = token.decode(encodedToken as string) + + if (await token.isValid(u)) { + await storage.setItem("ucan", encodedToken) + await storage.setItem("readKey", readKey) + } +} + // 🛳 @@ -110,10 +166,13 @@ export const IMPLEMENTATION: Implementation = { register, isUsernameValid, isUsernameAvailable, + createChannel, + checkCapability, + delegateAccount, + linkDevice } - // HELPERS diff --git a/src/auth/local.ts b/src/auth/local.ts index 0ed79fc11..0ea866947 100644 --- a/src/auth/local.ts +++ b/src/auth/local.ts @@ -1,16 +1,21 @@ +import type { Channel, ChannelOptions } from "./channel" + import { USERNAME_STORAGE_KEY } from "../common/index.js" import { State } from "./state.js" import { createAccount } from "../lobby/index.js" -import * as user from "../lobby/username.js" + +import * as channel from "./channel.js" +import * as did from "../did/index.js" import * as storage from "../storage/index.js" +import * as ucan from "../ucan/index.js" +import * as user from "../lobby/username.js" export const init = async (): Promise => { - console.log("initialize local auth") return new Promise((resolve) => resolve(null)) } -export const register = async (options: { username: string; email: string}): Promise<{success: boolean}> => { +export const register = async (options: { username: string; email: string }): Promise<{ success: boolean }> => { const { success } = await createAccount(options) if (success) { @@ -28,11 +33,69 @@ export const isUsernameAvailable = async (username: string): Promise => return user.isUsernameAvailable(username) } +export const createChannel = (options: ChannelOptions): Promise => { + return channel.createWssChannel(options) +} + +export const checkCapability = async (username: string): Promise => { + const didFromDNS = await did.root(username) + const maybeUcan: string | null = await storage.getItem("ucan") + + if (maybeUcan) { + const rootIssuerDid = ucan.rootIssuer(maybeUcan) + const decodedUcan = ucan.decode(maybeUcan) + const { ptc } = decodedUcan.payload + + return didFromDNS === rootIssuerDid && ptc === "SUPER_USER" + } else { + const rootDid = await did.write() + + return didFromDNS === rootDid + } +} + +export const delegateAccount = async (username: string, audience: string): Promise> => { + // Proof + const proof = await storage.getItem("ucan") as string + + // UCAN + const u = await ucan.build({ + audience, + issuer: await did.write(), + lifetimeInSeconds: 60 * 60 * 24 * 30 * 12 * 1000, // 1000 years + potency: "SUPER_USER", + proof, + + // TODO: UCAN v0.7.0 + // proofs: [ await localforage.getItem("ucan") ] + }) + + return { token: ucan.encode(u) } +} + +export const linkDevice = async (data: Record): Promise => { + const { token } = data + const u = ucan.decode(token as string) + + if (await ucan.isValid(u)) { + await storage.setItem("ucan", token) + } +} + + + +// 🛳 + + export const LOCAL_IMPLEMENTATION = { auth: { init, register, isUsernameValid, - isUsernameAvailable + isUsernameAvailable, + createChannel, + checkCapability, + delegateAccount, + linkDevice } } diff --git a/src/common/debug.ts b/src/common/debug.ts index 7a330afaf..d99985fc1 100644 --- a/src/common/debug.ts +++ b/src/common/debug.ts @@ -4,3 +4,7 @@ import { setup } from "../setup/internal.js" export function log(...args: unknown[]): void { if (setup.debug) console.log(...args) } + +export function warn(...args: unknown[]): void { + if (setup.debug) console.warn(...args) +} diff --git a/src/common/event-emitter.ts b/src/common/event-emitter.ts new file mode 100644 index 000000000..31608bdaa --- /dev/null +++ b/src/common/event-emitter.ts @@ -0,0 +1,32 @@ +export type EventListener = (event: E) => void + +export class EventEmitter { + private readonly events: Map>> = new Map() + + public on(eventName: K, listener: EventListener): void { + const eventSet = this.events.get(eventName) + + if (eventSet === undefined) { + this.events.set(eventName, new Set([listener]) as Set>) + } else { + eventSet.add(listener as EventListener) + } + } + + public removeListener(eventName: K, listener: EventListener): void { + const eventSet = this.events.get(eventName) + if (eventSet === undefined) return + + eventSet.delete(listener as EventListener) + + if (eventSet.size === 0) { + this.events.delete(eventName) + } + } + + public emit(eventName: K, event: EventMap[K]): void { + this.events.get(eventName)?.forEach((listener: EventListener) => { + listener.apply(this, [event]) + }) + } +} \ No newline at end of file diff --git a/src/common/types.ts b/src/common/types.ts index 1e340698a..09ae8e2ef 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -2,3 +2,7 @@ export type Maybe = T | null // https://codemix.com/opaque-types-in-javascript/ export type Opaque = T & { __TYPE__: K } + +export type Result = + | { ok: true; value: T } + | { ok: false; error: E } \ No newline at end of file diff --git a/src/common/version.ts b/src/common/version.ts index 7883ac977..2e3d9f412 100644 --- a/src/common/version.ts +++ b/src/common/version.ts @@ -1 +1 @@ -export const VERSION = "0.31.1" +export const VERSION = "0.32.0" diff --git a/src/index.ts b/src/index.ts index 718df238a..f9ba8a9a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,6 +99,7 @@ export { Scenario, State } from "./auth/state.js" export { AuthCancelled, AuthSucceeded, Continuation, NotAuthorised } from "./auth/state.js" export { InitialisationError, InitOptions } from "./init/types.js" +export * as account from "./auth/index.js" export * as apps from "./apps/index.js" export * as dataRoot from "./data-root.js" export * as did from "./did/index.js" diff --git a/tests/auth/linking.node.test.ts b/tests/auth/linking.node.test.ts new file mode 100644 index 000000000..81fc5d7d6 --- /dev/null +++ b/tests/auth/linking.node.test.ts @@ -0,0 +1,186 @@ +import expect from "expect" + +import { LOCAL_IMPLEMENTATION } from "../../src/auth/local.js" +import { createConsumer } from "../../src/auth/linking/consumer.js" +import { createProducer } from "../../src/auth/linking/producer.js" +import { EventEmitter } from "../../src/common/event-emitter.js" +import { debug, setImplementations } from "../../src/setup.js" + +import type { Channel, ChannelData, ChannelOptions } from "../../src/auth/channel.js" + +type MessageData = string | ArrayBufferLike | Blob | ArrayBufferView + +/** Test implementation + * The goal of test suite it to test the interaction between producers and consumers. Delegation + * and the capablility to delegate are not tested. + * + * The tests use an event emitter to emulate a networked communication channel. Delegating an account + * adds a username and a consumer DID to be linked to a list of producers. Linking an account adds a + * username and consumer DID to a list of consumers. + * + * If all consumers are linked at the end of a test, the consumers and producers should be the same. Errors + * or declined authorizations will set up different expectations. + * + */ + +describe("account linking", () => { + let channel: EventEmitter> = new EventEmitter() + let producerAccounts: Record = {} + let consumerAccounts: Record = {} + + before(() => { + const createChannel = async (options: ChannelOptions): Promise => { + const { username, handleMessage } = options + + const messageCallback = (data: MessageData) => { handleMessage(new MessageEvent(`${username}`, { data })) } + channel.on(`${username}`, messageCallback) + + return { + send: (data) => channel.emit(`${username}`, data), + close: () => channel.removeListener(`${username}`, messageCallback) + } + } + + const checkCapability = async (username: string): Promise => { + return true + } + + const delegateAccount = async (username: string, audience: string): Promise> => { + producerAccounts[username] = producerAccounts[username] ?? [] + producerAccounts[username] = [...producerAccounts[username], audience] + + return { username, audience } + } + + const linkDevice = async (data: Record): Promise => { + const { username, audience } = data as Record + + consumerAccounts[username] = consumerAccounts[username] ?? [] + consumerAccounts[username] = [...consumerAccounts[username], audience] + } + + setImplementations({ + auth: { + ...LOCAL_IMPLEMENTATION.auth, + createChannel, + checkCapability, + delegateAccount, + linkDevice + } + }) + + // debug({ enabled: true }) + }) + + afterEach(() => { + channel = new EventEmitter() + producerAccounts = {} + consumerAccounts = {} + }) + + it("links an account", async () => { + let consumerDone = false + + const producer = await createProducer({ username: "elm-owl" }) + + producer.on("challenge", ({ confirmPin }) => { + confirmPin() + }) + + const consumer = await createConsumer({ username: "elm-owl" }) + + consumer.on("done", () => { + consumerDone = true + }) + + while (!consumerDone) await new Promise(r => setTimeout(r, 1000)) + producer.cancel() + + expect(consumerAccounts["elm-owl"]).toBeDefined() + expect(consumerAccounts["elm-owl"].length).toEqual(1) + expect(producerAccounts["elm-owl"]).toBeDefined() + expect(producerAccounts["elm-owl"].length).toEqual(1) + expect(consumerAccounts).toEqual(producerAccounts) + }) + + it("links when consumer starts first", async () => { + let consumerDone = false + + const consumer = await createConsumer({ username: "elm-owl" }) + + consumer.on("done", () => { + consumerDone = true + }) + + const producer = await createProducer({ username: "elm-owl" }) + + producer.on("challenge", ({ confirmPin }) => { + confirmPin() + }) + + while (!consumerDone) await new Promise(r => setTimeout(r, 1000)) + producer.cancel() + + expect(consumerAccounts["elm-owl"]).toBeDefined() + expect(consumerAccounts["elm-owl"].length).toEqual(1) + expect(producerAccounts["elm-owl"]).toBeDefined() + expect(producerAccounts["elm-owl"].length).toEqual(1) + expect(consumerAccounts).toEqual(producerAccounts) + }) + + it("declines to link an account", async () => { + let consumerDone = false + + const producer = await createProducer({ username: "elm-owl" }) + + producer.on("challenge", ({ rejectPin }) => { + rejectPin() + }) + + const consumer = await createConsumer({ username: "elm-owl" }) + + consumer.on("done", () => { + consumerDone = true + }) + + while (!consumerDone) await new Promise(r => setTimeout(r, 1000)) + producer.cancel() + + expect(consumerAccounts["elm-owl"]).not.toBeDefined() + expect(producerAccounts["elm-owl"]).not.toBeDefined() + expect(consumerAccounts).toEqual(producerAccounts) + }) + + + // TODO: Run this test when we have implemented a message queue + it.skip("links with one producer and multiple consumers", async () => { + const numConsumers = Math.round(Math.random() * 2 + 2) + const producer = await createProducer({ username: "elm-owl" }) + + producer.on("challenge", ({ confirmPin }) => { + confirmPin() + }) + + const promisedConsumers = Array.from(Array(numConsumers)).map(async () => { + const emitter = await createConsumer({ username: "elm-owl" }) + const consumer = { emitter, done: false } + + consumer.emitter.on("done", () => { + consumer.done = true + }) + + return consumer + }) + + const consumers = await Promise.all(promisedConsumers) + + while (!consumers.every(consumer => consumer.done)) await new Promise(r => setTimeout(r, 1000)) + producer.cancel() + + expect(consumerAccounts["elm-owl"]).toBeDefined() + expect(consumerAccounts["elm-owl"].length).toEqual(numConsumers) + expect(producerAccounts["elm-owl"]).toBeDefined() + expect(producerAccounts["elm-owl"].length).toEqual(numConsumers) + expect(consumerAccounts).toEqual(producerAccounts) + }) +}) \ No newline at end of file diff --git a/tests/fs/api.private.node.test.ts b/tests/fs/api.private.node.test.ts index ea3baee5a..7a21349d4 100644 --- a/tests/fs/api.private.node.test.ts +++ b/tests/fs/api.private.node.test.ts @@ -15,7 +15,7 @@ import { privateFileContent as fileContent, privateDecode as decode } from "../h describe("the private filesystem api", function () { before(async function () { - fc.configureGlobal(process.env.TEST_ENV === "gh-action" ? { numRuns: 50 } : { numRuns: 10 }) + fc.configureGlobal(process.env.TEST_ENV === "gh-action" ? { numRuns: 25 } : { numRuns: 10 }) }) after(async () => { diff --git a/tests/fs/api.public.node.test.ts b/tests/fs/api.public.node.test.ts index 398d9471d..a32a06ac2 100644 --- a/tests/fs/api.public.node.test.ts +++ b/tests/fs/api.public.node.test.ts @@ -15,7 +15,7 @@ import { publicFileContent as fileContent, publicDecode as decode } from "../hel describe("the public filesystem api", function () { before(async function () { - fc.configureGlobal(process.env.TEST_ENV === "gh-action" ? { numRuns: 50 } : { numRuns: 10 }) + fc.configureGlobal(process.env.TEST_ENV === "gh-action" ? { numRuns: 25 } : { numRuns: 10 }) }) after(async () => {