@oxy.so/core 1.16.0 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +30 -1
- package/dist/cjs/crypto/identityLink.js +18 -0
- package/dist/cjs/i18n/locales/ar-SA.json +78 -3
- package/dist/cjs/i18n/locales/ca-ES.json +78 -3
- package/dist/cjs/i18n/locales/de-DE.json +78 -3
- package/dist/cjs/i18n/locales/en-US.json +75 -28
- package/dist/cjs/i18n/locales/es-ES.json +75 -28
- package/dist/cjs/i18n/locales/fr-FR.json +78 -3
- package/dist/cjs/i18n/locales/it-IT.json +78 -3
- package/dist/cjs/i18n/locales/ja-JP.json +78 -3
- package/dist/cjs/i18n/locales/ko-KR.json +78 -3
- package/dist/cjs/i18n/locales/locales/ar-SA.json +78 -3
- package/dist/cjs/i18n/locales/locales/ca-ES.json +78 -3
- package/dist/cjs/i18n/locales/locales/de-DE.json +78 -3
- package/dist/cjs/i18n/locales/locales/en-US.json +75 -28
- package/dist/cjs/i18n/locales/locales/es-ES.json +75 -28
- package/dist/cjs/i18n/locales/locales/fr-FR.json +78 -3
- package/dist/cjs/i18n/locales/locales/it-IT.json +78 -3
- package/dist/cjs/i18n/locales/locales/ja-JP.json +78 -3
- package/dist/cjs/i18n/locales/locales/ko-KR.json +78 -3
- package/dist/cjs/i18n/locales/locales/pt-PT.json +78 -3
- package/dist/cjs/i18n/locales/locales/zh-CN.json +78 -3
- package/dist/cjs/i18n/locales/pt-PT.json +78 -3
- package/dist/cjs/i18n/locales/zh-CN.json +78 -3
- package/dist/cjs/index.js +9 -42
- package/dist/cjs/mixins/OxyServices.auth.js +110 -17
- package/dist/cjs/mixins/OxyServices.identity.js +128 -8
- package/dist/cjs/mixins/OxyServices.user.js +42 -7
- package/dist/cjs/utils/authWebUrl.js +4 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +30 -1
- package/dist/esm/crypto/identityLink.js +15 -0
- package/dist/esm/i18n/locales/ar-SA.json +78 -3
- package/dist/esm/i18n/locales/ca-ES.json +78 -3
- package/dist/esm/i18n/locales/de-DE.json +78 -3
- package/dist/esm/i18n/locales/en-US.json +75 -28
- package/dist/esm/i18n/locales/es-ES.json +75 -28
- package/dist/esm/i18n/locales/fr-FR.json +78 -3
- package/dist/esm/i18n/locales/it-IT.json +78 -3
- package/dist/esm/i18n/locales/ja-JP.json +78 -3
- package/dist/esm/i18n/locales/ko-KR.json +78 -3
- package/dist/esm/i18n/locales/locales/ar-SA.json +78 -3
- package/dist/esm/i18n/locales/locales/ca-ES.json +78 -3
- package/dist/esm/i18n/locales/locales/de-DE.json +78 -3
- package/dist/esm/i18n/locales/locales/en-US.json +75 -28
- package/dist/esm/i18n/locales/locales/es-ES.json +75 -28
- package/dist/esm/i18n/locales/locales/fr-FR.json +78 -3
- package/dist/esm/i18n/locales/locales/it-IT.json +78 -3
- package/dist/esm/i18n/locales/locales/ja-JP.json +78 -3
- package/dist/esm/i18n/locales/locales/ko-KR.json +78 -3
- package/dist/esm/i18n/locales/locales/pt-PT.json +78 -3
- package/dist/esm/i18n/locales/locales/zh-CN.json +78 -3
- package/dist/esm/i18n/locales/pt-PT.json +78 -3
- package/dist/esm/i18n/locales/zh-CN.json +78 -3
- package/dist/esm/index.js +4 -4
- package/dist/esm/mixins/OxyServices.auth.js +110 -17
- package/dist/esm/mixins/OxyServices.identity.js +128 -8
- package/dist/esm/mixins/OxyServices.user.js +42 -7
- package/dist/esm/utils/authWebUrl.js +4 -5
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +21 -0
- package/dist/types/crypto/identityLink.d.ts +8 -0
- package/dist/types/index.d.ts +2 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +68 -25
- package/dist/types/mixins/OxyServices.identity.d.ts +34 -34
- package/dist/types/mixins/OxyServices.user.d.ts +29 -6
- package/dist/types/models/interfaces.d.ts +67 -13
- package/dist/types/server/securityHeaders.d.ts +3 -3
- package/dist/types/utils/authWebUrl.d.ts +4 -5
- package/package.json +3 -3
- package/src/HttpService.ts +35 -1
- package/src/__tests__/serviceIdentityWhenAnonymous.test.ts +159 -0
- package/src/crypto/__tests__/identityLink.test.ts +18 -0
- package/src/crypto/__tests__/identityProof.test.ts +6 -6
- package/src/crypto/identityLink.ts +18 -0
- package/src/i18n/locales/ar-SA.json +78 -3
- package/src/i18n/locales/ca-ES.json +78 -3
- package/src/i18n/locales/de-DE.json +78 -3
- package/src/i18n/locales/en-US.json +75 -28
- package/src/i18n/locales/es-ES.json +75 -28
- package/src/i18n/locales/fr-FR.json +78 -3
- package/src/i18n/locales/it-IT.json +78 -3
- package/src/i18n/locales/ja-JP.json +78 -3
- package/src/i18n/locales/ko-KR.json +78 -3
- package/src/i18n/locales/pt-PT.json +78 -3
- package/src/i18n/locales/zh-CN.json +78 -3
- package/src/index.ts +6 -48
- package/src/mixins/OxyServices.auth.ts +127 -24
- package/src/mixins/OxyServices.identity.ts +126 -8
- package/src/mixins/OxyServices.user.ts +51 -10
- package/src/mixins/__tests__/identityLink.test.ts +89 -0
- package/src/mixins/__tests__/notifications.read.test.ts +82 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +1 -1
- package/src/mixins/__tests__/webauthnAuth.test.ts +75 -1
- package/src/models/interfaces.ts +67 -12
- package/src/server/securityHeaders.ts +3 -3
- package/src/utils/authWebUrl.ts +4 -5
- package/dist/cjs/crypto/identityMove.js +0 -142
- package/dist/cjs/crypto/webIdentityCarrier.js +0 -325
- package/dist/esm/crypto/identityMove.js +0 -128
- package/dist/esm/crypto/webIdentityCarrier.js +0 -303
- package/dist/types/crypto/identityMove.d.ts +0 -75
- package/dist/types/crypto/webIdentityCarrier.d.ts +0 -150
- package/src/crypto/__tests__/identityMove.test.ts +0 -146
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +0 -169
- package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +0 -117
- package/src/crypto/identityMove.ts +0 -167
- package/src/crypto/webIdentityCarrier.ts +0 -394
|
@@ -372,9 +372,9 @@ export interface OxyPagesHeadersOptions {
|
|
|
372
372
|
* violation for a script we never asked for. The rest of the value is Pages'
|
|
373
373
|
* own default, so caching is unchanged.
|
|
374
374
|
*
|
|
375
|
-
* And, because this origin is
|
|
376
|
-
*
|
|
377
|
-
*
|
|
375
|
+
* And, because this origin is where a web account is created, recovered and
|
|
376
|
+
* deleted and every Oxy passkey is asserted (ADR 0028, ADR 0029) — the rest
|
|
377
|
+
* of the holder policy: no `<base>` and no form posts (`base-uri` /
|
|
378
378
|
* `form-action 'none'`), no Referer to anyone, passkeys for this origin only
|
|
379
379
|
* with every other powerful feature off (`Permissions-Policy`), and its
|
|
380
380
|
* responses readable by no other origin (`Cross-Origin-Resource-Policy`).
|
package/src/utils/authWebUrl.ts
CHANGED
|
@@ -17,10 +17,9 @@
|
|
|
17
17
|
export const CENTRAL_IDP_APEX = 'oxy.so';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* The IdP's origin, `auth.oxy.so` — the OAuth authorize/consent surface
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* open
|
|
24
|
-
* carriers). Apps open it; they never handle the identity themselves.
|
|
20
|
+
* The IdP's origin, `auth.oxy.so` — the OAuth authorize/consent surface and the
|
|
21
|
+
* one origin that asserts Oxy passkeys: where a person signs in, creates,
|
|
22
|
+
* recovers or deletes a web account, in the window an app opens (ADR 0029).
|
|
23
|
+
* Apps open it; they never run the passkey themselves.
|
|
25
24
|
*/
|
|
26
25
|
export const AUTH_WEB_ORIGIN = `https://auth.${CENTRAL_IDP_APEX}`;
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Identity move — the crypto of giving a web root to Commons (ADR 0024 D6).
|
|
4
|
-
*
|
|
5
|
-
* Both sides run this: the web holder (the initiator, holding the root) and
|
|
6
|
-
* Commons (the responder, receiving it). The relay in between sees a commitment,
|
|
7
|
-
* two ephemeral public keys and ciphertext. The initiator commits to its key
|
|
8
|
-
* before the responder chooses one and reveals it only afterwards, so the 6-digit
|
|
9
|
-
* code both screens show exposes a relay that substituted a key — it cannot grind
|
|
10
|
-
* one until the codes agree. The receipt binds the move, the root, both keys and
|
|
11
|
-
* the ciphertext actually relayed.
|
|
12
|
-
*
|
|
13
|
-
* PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
|
|
14
|
-
*/
|
|
15
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
-
exports.generateMoveEphemeralKeyPair = generateMoveEphemeralKeyPair;
|
|
17
|
-
exports.deriveMoveKey = deriveMoveKey;
|
|
18
|
-
exports.createMoveCommitment = createMoveCommitment;
|
|
19
|
-
exports.verifyMoveCommitment = verifyMoveCommitment;
|
|
20
|
-
exports.deriveMoveSas = deriveMoveSas;
|
|
21
|
-
exports.digestMoveCiphertext = digestMoveCiphertext;
|
|
22
|
-
exports.signMoveReceipt = signMoveReceipt;
|
|
23
|
-
exports.verifyMoveReceipt = verifyMoveReceipt;
|
|
24
|
-
exports.sealIdentityForMove = sealIdentityForMove;
|
|
25
|
-
exports.openMovedIdentity = openMovedIdentity;
|
|
26
|
-
exports.buildMoveQrPayload = buildMoveQrPayload;
|
|
27
|
-
exports.parseMoveQrPayload = parseMoveQrPayload;
|
|
28
|
-
require("./polyfill");
|
|
29
|
-
const bip39_1 = require("@scure/bip39");
|
|
30
|
-
const english_1 = require("@scure/bip39/wordlists/english");
|
|
31
|
-
const sha256_1 = require("@noble/hashes/sha256");
|
|
32
|
-
const utils_1 = require("@noble/hashes/utils");
|
|
33
|
-
const protocol_1 = require("@oxy.so/protocol");
|
|
34
|
-
const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
|
|
35
|
-
const contracts_1 = require("@oxy.so/contracts");
|
|
36
|
-
const aead_1 = require("./aead");
|
|
37
|
-
const ecdh_1 = require("./ecdh");
|
|
38
|
-
const kdf_1 = require("./kdf");
|
|
39
|
-
const webIdentityCarrier_1 = require("./webIdentityCarrier");
|
|
40
|
-
const MOVE_KDF_INFO = (0, utils_1.utf8ToBytes)('oxy-identity-move-v1');
|
|
41
|
-
function sha256Hex(input) {
|
|
42
|
-
return (0, utils_1.bytesToHex)((0, sha256_1.sha256)((0, utils_1.utf8ToBytes)(input)));
|
|
43
|
-
}
|
|
44
|
-
/** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
|
|
45
|
-
function generateMoveEphemeralKeyPair() {
|
|
46
|
-
return (0, secp256k1_1.generateSecp256k1KeyPair)();
|
|
47
|
-
}
|
|
48
|
-
/** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
|
|
49
|
-
function deriveMoveKey(ownEphemeralPrivateKey, otherEphemeralPublicKey, moveId) {
|
|
50
|
-
const shared = (0, ecdh_1.deriveSharedSecret)(ownEphemeralPrivateKey, otherEphemeralPublicKey);
|
|
51
|
-
try {
|
|
52
|
-
return (0, kdf_1.hkdfSha256)(shared, (0, utils_1.utf8ToBytes)(moveId.toLowerCase()), MOVE_KDF_INFO, aead_1.AEAD_KEY_LENGTH);
|
|
53
|
-
}
|
|
54
|
-
finally {
|
|
55
|
-
(0, webIdentityCarrier_1.wipeBytes)(shared);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
/** Initiator: commit to the ephemeral key before anyone else's key is known. The nonce stays in memory until the reveal. */
|
|
59
|
-
function createMoveCommitment(initiatorEphemeralPublicKey) {
|
|
60
|
-
const nonceBytes = new Uint8Array(32);
|
|
61
|
-
globalThis.crypto.getRandomValues(nonceBytes);
|
|
62
|
-
const nonce = (0, utils_1.bytesToHex)(nonceBytes);
|
|
63
|
-
return { commitment: sha256Hex((0, contracts_1.buildMoveCommitmentInput)(initiatorEphemeralPublicKey, nonce)), nonce };
|
|
64
|
-
}
|
|
65
|
-
/** Responder: does the revealed key match the commitment read before joining? */
|
|
66
|
-
function verifyMoveCommitment(initiatorEphemeralPublicKey, nonce, commitment) {
|
|
67
|
-
return sha256Hex((0, contracts_1.buildMoveCommitmentInput)(initiatorEphemeralPublicKey, nonce)) === commitment.toLowerCase();
|
|
68
|
-
}
|
|
69
|
-
/** The 6-digit code, bound to the move, both keys in their roles, and the commitment. */
|
|
70
|
-
function deriveMoveSas(input) {
|
|
71
|
-
const digest = (0, sha256_1.sha256)((0, utils_1.utf8ToBytes)((0, contracts_1.buildMoveSasInput)(input)));
|
|
72
|
-
const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
|
|
73
|
-
return String(value % 1000000).padStart(6, '0');
|
|
74
|
-
}
|
|
75
|
-
/** The digest a receipt binds: what was actually relayed. */
|
|
76
|
-
function digestMoveCiphertext(sealed) {
|
|
77
|
-
return sha256Hex((0, contracts_1.buildMoveCiphertextDigestInput)(sealed));
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Responder: sign the receipt with a signer that reads the root back from durable
|
|
81
|
-
* storage (Commons passes its keychain signer), so a receipt exists only for a
|
|
82
|
-
* root that was actually stored.
|
|
83
|
-
*/
|
|
84
|
-
async function signMoveReceipt(sign, claims) {
|
|
85
|
-
return { signature: await sign((0, contracts_1.buildMoveReceiptMessage)(claims)) };
|
|
86
|
-
}
|
|
87
|
-
/** Initiator: is this receipt the root's, for exactly this move and ciphertext? */
|
|
88
|
-
function verifyMoveReceipt(claims, signature) {
|
|
89
|
-
return (0, protocol_1.verifySignature)((0, contracts_1.buildMoveReceiptMessage)(claims), signature, claims.rootPublicKey);
|
|
90
|
-
}
|
|
91
|
-
function moveAad(moveId, publicKey) {
|
|
92
|
-
return (0, utils_1.utf8ToBytes)(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
|
|
93
|
-
}
|
|
94
|
-
/** Initiator: seal the phrase entropy (12–24 words) for the responder. */
|
|
95
|
-
function sealIdentityForMove(identity, moveKey, moveId) {
|
|
96
|
-
if (typeof identity.mnemonic !== 'string' || !identity.mnemonic) {
|
|
97
|
-
throw new Error('Only an identity with a recovery phrase can be moved this way');
|
|
98
|
-
}
|
|
99
|
-
const entropy = (0, bip39_1.mnemonicToEntropy)(identity.mnemonic, english_1.wordlist);
|
|
100
|
-
try {
|
|
101
|
-
const { nonce, ciphertext } = (0, aead_1.encryptAead)(moveKey, entropy, moveAad(moveId, identity.publicKey));
|
|
102
|
-
return { nonce: (0, utils_1.bytesToHex)(nonce), ciphertext: (0, utils_1.bytesToHex)(ciphertext) };
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
(0, webIdentityCarrier_1.wipeBytes)(entropy);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Responder: open the sealed identity. The derived public key must be the one
|
|
110
|
-
* the move declared — anything else is refused, never imported.
|
|
111
|
-
*/
|
|
112
|
-
function openMovedIdentity(sealed, moveKey, moveId, expectedPublicKey) {
|
|
113
|
-
let entropy;
|
|
114
|
-
try {
|
|
115
|
-
entropy = (0, aead_1.decryptAead)(moveKey, (0, utils_1.hexToBytes)(sealed.nonce), (0, utils_1.hexToBytes)(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
throw new Error('The identity could not be opened. The codes may not have matched — start again.');
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
const identity = (0, webIdentityCarrier_1.deriveIdentityFromMnemonic)((0, bip39_1.entropyToMnemonic)(entropy, english_1.wordlist));
|
|
122
|
-
if (identity.publicKey !== expectedPublicKey.toLowerCase()) {
|
|
123
|
-
throw new Error('The received identity is not the one being moved');
|
|
124
|
-
}
|
|
125
|
-
return identity;
|
|
126
|
-
}
|
|
127
|
-
finally {
|
|
128
|
-
(0, webIdentityCarrier_1.wipeBytes)(entropy);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
/** The QR payload for a move. */
|
|
132
|
-
function buildMoveQrPayload(moveId) {
|
|
133
|
-
return `${contracts_1.IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
|
|
134
|
-
}
|
|
135
|
-
/** The move id in a scanned payload, or `null` when it is not a move QR. */
|
|
136
|
-
function parseMoveQrPayload(raw) {
|
|
137
|
-
const value = raw.trim();
|
|
138
|
-
if (!value.toLowerCase().startsWith(contracts_1.IDENTITY_MOVE_QR_PREFIX))
|
|
139
|
-
return null;
|
|
140
|
-
const id = value.slice(contracts_1.IDENTITY_MOVE_QR_PREFIX.length).toLowerCase();
|
|
141
|
-
return /^[0-9a-f]{32}$/.test(id) ? id : null;
|
|
142
|
-
}
|
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Web identity holder — the crypto of a browser holding a personal root (ADR 0024).
|
|
4
|
-
*
|
|
5
|
-
* An Oxy root is usually a BIP-39 mnemonic whose seed's first 32 bytes are the
|
|
6
|
-
* secp256k1 key (`recoveryPhrase.ts`), and for a few imported identities a raw
|
|
7
|
-
* private key with no phrase. Commons keeps it in the device keychain. This
|
|
8
|
-
* module lets a browser hold the SAME root without Oxy ever being able to use it:
|
|
9
|
-
*
|
|
10
|
-
secret ──AEAD(DEK)──▶ sealed secret (phrase entropy or raw key)
|
|
11
|
-
* DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
|
|
12
|
-
*
|
|
13
|
-
* The PRF output is produced inside the user's authenticator, behind user
|
|
14
|
-
* verification, and never leaves the page that asked for it. The envelope is
|
|
15
|
-
* therefore safe to store anywhere — auth.oxy.so's IndexedDB and the server
|
|
16
|
-
* copy alike — and opens only with a registered passkey or the recovery material.
|
|
17
|
-
*
|
|
18
|
-
* PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
|
|
19
|
-
* CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
|
|
20
|
-
* PRF output in; everything here is deterministic given its inputs, so it is
|
|
21
|
-
* identical on web, Node and React Native, and fully unit-testable.
|
|
22
|
-
*
|
|
23
|
-
* Contract: `WebIdentityEnvelope` in `@oxy.so/contracts`.
|
|
24
|
-
*/
|
|
25
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.WebIdentityUnlockError = exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH = exports.WEB_IDENTITY_PRF_INPUT = void 0;
|
|
27
|
-
exports.wipeBytes = wipeBytes;
|
|
28
|
-
exports.wipeOpenedIdentity = wipeOpenedIdentity;
|
|
29
|
-
exports.normalizeMnemonic = normalizeMnemonic;
|
|
30
|
-
exports.deriveIdentityFromMnemonic = deriveIdentityFromMnemonic;
|
|
31
|
-
exports.deriveIdentityFromPrivateKey = deriveIdentityFromPrivateKey;
|
|
32
|
-
exports.parseRecoveryMaterial = parseRecoveryMaterial;
|
|
33
|
-
exports.deriveIdentityFromRecoveryMaterial = deriveIdentityFromRecoveryMaterial;
|
|
34
|
-
exports.generateWebIdentity = generateWebIdentity;
|
|
35
|
-
exports.generateDataKey = generateDataKey;
|
|
36
|
-
exports.isUsablePrfOutput = isUsablePrfOutput;
|
|
37
|
-
exports.deriveKeyEncryptionKey = deriveKeyEncryptionKey;
|
|
38
|
-
exports.sealWebIdentity = sealWebIdentity;
|
|
39
|
-
exports.unwrapDataKey = unwrapDataKey;
|
|
40
|
-
exports.openWebIdentity = openWebIdentity;
|
|
41
|
-
exports.unlockWebIdentity = unlockWebIdentity;
|
|
42
|
-
exports.addWrap = addWrap;
|
|
43
|
-
exports.markWrapVerified = markWrapVerified;
|
|
44
|
-
exports.removeWrap = removeWrap;
|
|
45
|
-
require("./polyfill");
|
|
46
|
-
const bip39_1 = require("@scure/bip39");
|
|
47
|
-
const english_1 = require("@scure/bip39/wordlists/english");
|
|
48
|
-
const sha256_1 = require("@noble/hashes/sha256");
|
|
49
|
-
const utils_1 = require("@noble/hashes/utils");
|
|
50
|
-
const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
|
|
51
|
-
const contracts_1 = require("@oxy.so/contracts");
|
|
52
|
-
const aead_1 = require("./aead");
|
|
53
|
-
const kdf_1 = require("./kdf");
|
|
54
|
-
/**
|
|
55
|
-
* The PRF input every identity-carrier ceremony evaluates (`prf.eval.first`).
|
|
56
|
-
*
|
|
57
|
-
* Fixed rather than per-wrap: discoverable sign-in has no credential list to key
|
|
58
|
-
* `evalByCredential` by, and each passkey's output is already distinct. A future
|
|
59
|
-
* scheme change bumps the label — and with it every KEK — instead of mutating it.
|
|
60
|
-
*/
|
|
61
|
-
exports.WEB_IDENTITY_PRF_INPUT = (0, sha256_1.sha256)((0, utils_1.utf8ToBytes)('oxy-web-identity-carrier/prf/v1'));
|
|
62
|
-
/** HKDF salt of the key-encryption-key schedule. Shared by envelope versions 1 and 2. */
|
|
63
|
-
const KEK_SALT = (0, utils_1.utf8ToBytes)('oxy-web-identity-carrier/kek/v1');
|
|
64
|
-
/** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). Nothing shorter or longer is usable. */
|
|
65
|
-
exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH = 32;
|
|
66
|
-
class WebIdentityUnlockError extends Error {
|
|
67
|
-
constructor(failure, message) {
|
|
68
|
-
super(message);
|
|
69
|
-
this.failure = failure;
|
|
70
|
-
this.name = 'WebIdentityUnlockError';
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
exports.WebIdentityUnlockError = WebIdentityUnlockError;
|
|
74
|
-
function aadFor(parts) {
|
|
75
|
-
return (0, utils_1.utf8ToBytes)(JSON.stringify(parts));
|
|
76
|
-
}
|
|
77
|
-
function secretAad(publicKey, secretKind) {
|
|
78
|
-
return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'secret', secretKind, publicKey });
|
|
79
|
-
}
|
|
80
|
-
function wrapAad(publicKey, credentialId, rpId) {
|
|
81
|
-
return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId, rpId });
|
|
82
|
-
}
|
|
83
|
-
/** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
|
|
84
|
-
function wipeBytes(bytes) {
|
|
85
|
-
bytes.fill(0);
|
|
86
|
-
}
|
|
87
|
-
/** Best-effort removal of secret strings from an opened identity. */
|
|
88
|
-
function wipeOpenedIdentity(identity) {
|
|
89
|
-
identity.privateKey = '';
|
|
90
|
-
if (identity.kind === 'mnemonic')
|
|
91
|
-
identity.mnemonic = '';
|
|
92
|
-
}
|
|
93
|
-
/** Normalize a typed phrase: trimmed, lowercase, single spaces. */
|
|
94
|
-
function normalizeMnemonic(mnemonic) {
|
|
95
|
-
return mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Derive the identity from a mnemonic exactly as Commons does
|
|
99
|
-
* (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1. Any standard
|
|
100
|
-
* BIP-39 length (12, 15, 18, 21, 24 words) is accepted; the derivation does not
|
|
101
|
-
* depend on the length.
|
|
102
|
-
*/
|
|
103
|
-
function deriveIdentityFromMnemonic(mnemonic) {
|
|
104
|
-
const normalized = normalizeMnemonic(mnemonic);
|
|
105
|
-
if (!(0, bip39_1.validateMnemonic)(normalized, english_1.wordlist)) {
|
|
106
|
-
throw new Error('Invalid recovery phrase');
|
|
107
|
-
}
|
|
108
|
-
const seed = (0, bip39_1.mnemonicToSeedSync)(normalized);
|
|
109
|
-
const privateKeyBytes = seed.slice(0, 32);
|
|
110
|
-
wipeBytes(seed);
|
|
111
|
-
const privateKey = (0, utils_1.bytesToHex)(privateKeyBytes);
|
|
112
|
-
wipeBytes(privateKeyBytes);
|
|
113
|
-
return { kind: 'mnemonic', mnemonic: normalized, privateKey, publicKey: (0, secp256k1_1.deriveSecp256k1PublicKey)(privateKey) };
|
|
114
|
-
}
|
|
115
|
-
/** A raw 32-byte private key (hex, optional `0x`) as an identity. Never gains a phrase. */
|
|
116
|
-
function deriveIdentityFromPrivateKey(privateKey) {
|
|
117
|
-
const normalized = privateKey.trim().toLowerCase().replace(/^0x/, '');
|
|
118
|
-
if (!/^[0-9a-f]{64}$/.test(normalized)) {
|
|
119
|
-
throw new Error('Invalid private key');
|
|
120
|
-
}
|
|
121
|
-
let publicKey;
|
|
122
|
-
try {
|
|
123
|
-
publicKey = (0, secp256k1_1.deriveSecp256k1PublicKey)(normalized);
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
throw new Error('Invalid private key');
|
|
127
|
-
}
|
|
128
|
-
return { kind: 'raw-key', mnemonic: null, privateKey: normalized, publicKey };
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Read what a person typed as recovery material: a BIP-39 phrase, or a 64-hex
|
|
132
|
-
* private key. Throws `Invalid recovery material` for anything else.
|
|
133
|
-
*/
|
|
134
|
-
function parseRecoveryMaterial(input) {
|
|
135
|
-
const trimmed = input.trim();
|
|
136
|
-
if (/^(0x)?[0-9a-fA-F]{64}$/.test(trimmed))
|
|
137
|
-
return { kind: 'raw-key', privateKey: trimmed };
|
|
138
|
-
const words = normalizeMnemonic(trimmed);
|
|
139
|
-
if (words && (0, bip39_1.validateMnemonic)(words, english_1.wordlist))
|
|
140
|
-
return { kind: 'mnemonic', mnemonic: words };
|
|
141
|
-
throw new Error('Invalid recovery material');
|
|
142
|
-
}
|
|
143
|
-
/** Derive the identity recovery material names. */
|
|
144
|
-
function deriveIdentityFromRecoveryMaterial(material) {
|
|
145
|
-
return material.kind === 'mnemonic'
|
|
146
|
-
? deriveIdentityFromMnemonic(material.mnemonic)
|
|
147
|
-
: deriveIdentityFromPrivateKey(material.privateKey);
|
|
148
|
-
}
|
|
149
|
-
/** A brand-new 12-word identity. Nothing is persisted. */
|
|
150
|
-
function generateWebIdentity() {
|
|
151
|
-
return deriveIdentityFromMnemonic((0, bip39_1.generateMnemonic)(english_1.wordlist, 128));
|
|
152
|
-
}
|
|
153
|
-
/** A fresh random data key for a new envelope. */
|
|
154
|
-
function generateDataKey() {
|
|
155
|
-
const key = new Uint8Array(aead_1.AEAD_KEY_LENGTH);
|
|
156
|
-
globalThis.crypto.getRandomValues(key);
|
|
157
|
-
return key;
|
|
158
|
-
}
|
|
159
|
-
/** Whether `bytes` is a usable PRF output: exactly 32 bytes. */
|
|
160
|
-
function isUsablePrfOutput(bytes) {
|
|
161
|
-
return bytes instanceof Uint8Array && bytes.byteLength === exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH;
|
|
162
|
-
}
|
|
163
|
-
/** The key-encryption key a passkey's PRF output yields for its own wrap. */
|
|
164
|
-
function deriveKeyEncryptionKey(prfOutput, credentialId) {
|
|
165
|
-
if (prfOutput.length !== exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH) {
|
|
166
|
-
throw new Error(`PRF output must be ${exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
|
|
167
|
-
}
|
|
168
|
-
return (0, kdf_1.hkdfSha256)(prfOutput, KEK_SALT, (0, utils_1.utf8ToBytes)(credentialId), aead_1.AEAD_KEY_LENGTH);
|
|
169
|
-
}
|
|
170
|
-
function wrapFor(publicKey, dataKey, input, now) {
|
|
171
|
-
const kek = deriveKeyEncryptionKey(input.prfOutput, input.credentialId);
|
|
172
|
-
try {
|
|
173
|
-
const { nonce, ciphertext } = (0, aead_1.encryptAead)(kek, dataKey, wrapAad(publicKey, input.credentialId, input.rpId));
|
|
174
|
-
return {
|
|
175
|
-
credentialId: input.credentialId,
|
|
176
|
-
nonce: (0, utils_1.bytesToHex)(nonce),
|
|
177
|
-
wrappedKey: (0, utils_1.bytesToHex)(ciphertext),
|
|
178
|
-
createdAt: now.toISOString(),
|
|
179
|
-
rpId: input.rpId,
|
|
180
|
-
...(input.verifiedAt ? { verifiedAt: input.verifiedAt } : {}),
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
finally {
|
|
184
|
-
wipeBytes(kek);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
function secretOf(identity) {
|
|
188
|
-
if (identity.kind === 'mnemonic') {
|
|
189
|
-
return { kind: 'mnemonic-entropy', bytes: (0, bip39_1.mnemonicToEntropy)(identity.mnemonic, english_1.wordlist) };
|
|
190
|
-
}
|
|
191
|
-
return { kind: 'raw-private-key', bytes: (0, utils_1.hexToBytes)(identity.privateKey) };
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Seal an identity into a new envelope that opens with the given passkey.
|
|
195
|
-
*
|
|
196
|
-
* Returns the data key too, so the caller can add further wraps in the same
|
|
197
|
-
* session; wipe it when done.
|
|
198
|
-
*/
|
|
199
|
-
function sealWebIdentity(identity, firstWrap, now = new Date()) {
|
|
200
|
-
const derived = identity.kind === 'raw-key' ? deriveIdentityFromPrivateKey(identity.privateKey) : deriveIdentityFromMnemonic(identity.mnemonic);
|
|
201
|
-
const publicKey = identity.publicKey.toLowerCase();
|
|
202
|
-
if (derived.publicKey !== publicKey) {
|
|
203
|
-
wipeOpenedIdentity(derived);
|
|
204
|
-
throw new Error('The recovery material does not belong to this identity');
|
|
205
|
-
}
|
|
206
|
-
const secret = secretOf(derived);
|
|
207
|
-
wipeOpenedIdentity(derived);
|
|
208
|
-
const dataKey = generateDataKey();
|
|
209
|
-
try {
|
|
210
|
-
const { nonce, ciphertext } = (0, aead_1.encryptAead)(dataKey, secret.bytes, secretAad(publicKey, secret.kind));
|
|
211
|
-
const envelope = {
|
|
212
|
-
version: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION,
|
|
213
|
-
algorithm: 'xchacha20poly1305',
|
|
214
|
-
publicKey,
|
|
215
|
-
secretKind: secret.kind,
|
|
216
|
-
secretNonce: (0, utils_1.bytesToHex)(nonce),
|
|
217
|
-
sealedSecret: (0, utils_1.bytesToHex)(ciphertext),
|
|
218
|
-
wraps: [wrapFor(publicKey, dataKey, firstWrap, now)],
|
|
219
|
-
};
|
|
220
|
-
return { envelope, dataKey };
|
|
221
|
-
}
|
|
222
|
-
catch (error) {
|
|
223
|
-
wipeBytes(dataKey);
|
|
224
|
-
throw error;
|
|
225
|
-
}
|
|
226
|
-
finally {
|
|
227
|
-
wipeBytes(secret.bytes);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
/** Recover the data key with one passkey's PRF output. */
|
|
231
|
-
function unwrapDataKey(envelope, prfOutput, credentialId) {
|
|
232
|
-
const wrap = envelope.wraps.find((entry) => entry.credentialId === credentialId);
|
|
233
|
-
if (!wrap) {
|
|
234
|
-
throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
|
|
235
|
-
}
|
|
236
|
-
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
237
|
-
try {
|
|
238
|
-
return (0, aead_1.decryptAead)(kek, (0, utils_1.hexToBytes)(wrap.nonce), (0, utils_1.hexToBytes)(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId, wrap.rpId));
|
|
239
|
-
}
|
|
240
|
-
catch {
|
|
241
|
-
throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
|
|
242
|
-
}
|
|
243
|
-
finally {
|
|
244
|
-
wipeBytes(kek);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Open an envelope with its data key.
|
|
249
|
-
*
|
|
250
|
-
* The derived public key is checked against the envelope's own: a sealed secret
|
|
251
|
-
* that decrypts into a different identity is treated as corruption, never used.
|
|
252
|
-
*/
|
|
253
|
-
function openWebIdentity(envelope, dataKey) {
|
|
254
|
-
const publicKey = envelope.publicKey.toLowerCase();
|
|
255
|
-
const kind = envelope.secretKind;
|
|
256
|
-
let secret;
|
|
257
|
-
try {
|
|
258
|
-
secret = (0, aead_1.decryptAead)(dataKey, (0, utils_1.hexToBytes)(envelope.secretNonce), (0, utils_1.hexToBytes)(envelope.sealedSecret), secretAad(envelope.publicKey, kind));
|
|
259
|
-
}
|
|
260
|
-
catch {
|
|
261
|
-
throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
|
|
262
|
-
}
|
|
263
|
-
try {
|
|
264
|
-
let identity;
|
|
265
|
-
try {
|
|
266
|
-
identity =
|
|
267
|
-
kind === 'mnemonic-entropy'
|
|
268
|
-
? deriveIdentityFromMnemonic((0, bip39_1.entropyToMnemonic)(secret, english_1.wordlist))
|
|
269
|
-
: deriveIdentityFromPrivateKey((0, utils_1.bytesToHex)(secret));
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
throw new WebIdentityUnlockError('corrupt', 'The sealed identity is malformed');
|
|
273
|
-
}
|
|
274
|
-
if (identity.publicKey !== publicKey) {
|
|
275
|
-
wipeOpenedIdentity(identity);
|
|
276
|
-
throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
|
|
277
|
-
}
|
|
278
|
-
return identity;
|
|
279
|
-
}
|
|
280
|
-
finally {
|
|
281
|
-
wipeBytes(secret);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
/** Unwrap and open in one step. */
|
|
285
|
-
function unlockWebIdentity(envelope, prfOutput, credentialId) {
|
|
286
|
-
const dataKey = unwrapDataKey(envelope, prfOutput, credentialId);
|
|
287
|
-
try {
|
|
288
|
-
return openWebIdentity(envelope, dataKey);
|
|
289
|
-
}
|
|
290
|
-
finally {
|
|
291
|
-
wipeBytes(dataKey);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
/** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
|
|
295
|
-
function addWrap(envelope, dataKey, wrapInput, now = new Date()) {
|
|
296
|
-
// Proves `dataKey` belongs to THIS envelope before anything new can open it.
|
|
297
|
-
wipeOpenedIdentity(openWebIdentity(envelope, dataKey));
|
|
298
|
-
const others = envelope.wraps.filter((entry) => entry.credentialId !== wrapInput.credentialId);
|
|
299
|
-
return {
|
|
300
|
-
...envelope,
|
|
301
|
-
wraps: [...others, wrapFor(envelope.publicKey, dataKey, wrapInput, now)],
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Record that a wrap's passkey opened the envelope in a ceremony of its own. The
|
|
306
|
-
* AEAD does not cover `verifiedAt`; it is holder metadata the owner writes with a
|
|
307
|
-
* root proof, not a cryptographic claim.
|
|
308
|
-
*/
|
|
309
|
-
function markWrapVerified(envelope, credentialId, now = new Date()) {
|
|
310
|
-
if (!envelope.wraps.some((entry) => entry.credentialId === credentialId)) {
|
|
311
|
-
throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
|
|
312
|
-
}
|
|
313
|
-
return {
|
|
314
|
-
...envelope,
|
|
315
|
-
wraps: envelope.wraps.map((entry) => entry.credentialId === credentialId ? { ...entry, verifiedAt: now.toISOString() } : entry),
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
/** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
|
|
319
|
-
function removeWrap(envelope, credentialId) {
|
|
320
|
-
const remaining = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
|
|
321
|
-
if (remaining.length === 0) {
|
|
322
|
-
throw new Error('An identity envelope must keep at least one passkey');
|
|
323
|
-
}
|
|
324
|
-
return { ...envelope, wraps: remaining };
|
|
325
|
-
}
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Identity move — the crypto of giving a web root to Commons (ADR 0024 D6).
|
|
3
|
-
*
|
|
4
|
-
* Both sides run this: the web holder (the initiator, holding the root) and
|
|
5
|
-
* Commons (the responder, receiving it). The relay in between sees a commitment,
|
|
6
|
-
* two ephemeral public keys and ciphertext. The initiator commits to its key
|
|
7
|
-
* before the responder chooses one and reveals it only afterwards, so the 6-digit
|
|
8
|
-
* code both screens show exposes a relay that substituted a key — it cannot grind
|
|
9
|
-
* one until the codes agree. The receipt binds the move, the root, both keys and
|
|
10
|
-
* the ciphertext actually relayed.
|
|
11
|
-
*
|
|
12
|
-
* PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
|
|
13
|
-
*/
|
|
14
|
-
import './polyfill.js';
|
|
15
|
-
import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
|
|
16
|
-
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
17
|
-
import { sha256 } from '@noble/hashes/sha256';
|
|
18
|
-
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
19
|
-
import { verifySignature } from '@oxy.so/protocol';
|
|
20
|
-
import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
|
|
21
|
-
import { IDENTITY_MOVE_QR_PREFIX, buildMoveCiphertextDigestInput, buildMoveCommitmentInput, buildMoveReceiptMessage, buildMoveSasInput, } from '@oxy.so/contracts';
|
|
22
|
-
import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead.js';
|
|
23
|
-
import { deriveSharedSecret } from './ecdh.js';
|
|
24
|
-
import { hkdfSha256 } from './kdf.js';
|
|
25
|
-
import { deriveIdentityFromMnemonic, wipeBytes } from './webIdentityCarrier.js';
|
|
26
|
-
const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
|
|
27
|
-
function sha256Hex(input) {
|
|
28
|
-
return bytesToHex(sha256(utf8ToBytes(input)));
|
|
29
|
-
}
|
|
30
|
-
/** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
|
|
31
|
-
export function generateMoveEphemeralKeyPair() {
|
|
32
|
-
return generateSecp256k1KeyPair();
|
|
33
|
-
}
|
|
34
|
-
/** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
|
|
35
|
-
export function deriveMoveKey(ownEphemeralPrivateKey, otherEphemeralPublicKey, moveId) {
|
|
36
|
-
const shared = deriveSharedSecret(ownEphemeralPrivateKey, otherEphemeralPublicKey);
|
|
37
|
-
try {
|
|
38
|
-
return hkdfSha256(shared, utf8ToBytes(moveId.toLowerCase()), MOVE_KDF_INFO, AEAD_KEY_LENGTH);
|
|
39
|
-
}
|
|
40
|
-
finally {
|
|
41
|
-
wipeBytes(shared);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
/** Initiator: commit to the ephemeral key before anyone else's key is known. The nonce stays in memory until the reveal. */
|
|
45
|
-
export function createMoveCommitment(initiatorEphemeralPublicKey) {
|
|
46
|
-
const nonceBytes = new Uint8Array(32);
|
|
47
|
-
globalThis.crypto.getRandomValues(nonceBytes);
|
|
48
|
-
const nonce = bytesToHex(nonceBytes);
|
|
49
|
-
return { commitment: sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)), nonce };
|
|
50
|
-
}
|
|
51
|
-
/** Responder: does the revealed key match the commitment read before joining? */
|
|
52
|
-
export function verifyMoveCommitment(initiatorEphemeralPublicKey, nonce, commitment) {
|
|
53
|
-
return sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)) === commitment.toLowerCase();
|
|
54
|
-
}
|
|
55
|
-
/** The 6-digit code, bound to the move, both keys in their roles, and the commitment. */
|
|
56
|
-
export function deriveMoveSas(input) {
|
|
57
|
-
const digest = sha256(utf8ToBytes(buildMoveSasInput(input)));
|
|
58
|
-
const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
|
|
59
|
-
return String(value % 1000000).padStart(6, '0');
|
|
60
|
-
}
|
|
61
|
-
/** The digest a receipt binds: what was actually relayed. */
|
|
62
|
-
export function digestMoveCiphertext(sealed) {
|
|
63
|
-
return sha256Hex(buildMoveCiphertextDigestInput(sealed));
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Responder: sign the receipt with a signer that reads the root back from durable
|
|
67
|
-
* storage (Commons passes its keychain signer), so a receipt exists only for a
|
|
68
|
-
* root that was actually stored.
|
|
69
|
-
*/
|
|
70
|
-
export async function signMoveReceipt(sign, claims) {
|
|
71
|
-
return { signature: await sign(buildMoveReceiptMessage(claims)) };
|
|
72
|
-
}
|
|
73
|
-
/** Initiator: is this receipt the root's, for exactly this move and ciphertext? */
|
|
74
|
-
export function verifyMoveReceipt(claims, signature) {
|
|
75
|
-
return verifySignature(buildMoveReceiptMessage(claims), signature, claims.rootPublicKey);
|
|
76
|
-
}
|
|
77
|
-
function moveAad(moveId, publicKey) {
|
|
78
|
-
return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
|
|
79
|
-
}
|
|
80
|
-
/** Initiator: seal the phrase entropy (12–24 words) for the responder. */
|
|
81
|
-
export function sealIdentityForMove(identity, moveKey, moveId) {
|
|
82
|
-
if (typeof identity.mnemonic !== 'string' || !identity.mnemonic) {
|
|
83
|
-
throw new Error('Only an identity with a recovery phrase can be moved this way');
|
|
84
|
-
}
|
|
85
|
-
const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
|
|
86
|
-
try {
|
|
87
|
-
const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
|
|
88
|
-
return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(ciphertext) };
|
|
89
|
-
}
|
|
90
|
-
finally {
|
|
91
|
-
wipeBytes(entropy);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Responder: open the sealed identity. The derived public key must be the one
|
|
96
|
-
* the move declared — anything else is refused, never imported.
|
|
97
|
-
*/
|
|
98
|
-
export function openMovedIdentity(sealed, moveKey, moveId, expectedPublicKey) {
|
|
99
|
-
let entropy;
|
|
100
|
-
try {
|
|
101
|
-
entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
throw new Error('The identity could not be opened. The codes may not have matched — start again.');
|
|
105
|
-
}
|
|
106
|
-
try {
|
|
107
|
-
const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
|
|
108
|
-
if (identity.publicKey !== expectedPublicKey.toLowerCase()) {
|
|
109
|
-
throw new Error('The received identity is not the one being moved');
|
|
110
|
-
}
|
|
111
|
-
return identity;
|
|
112
|
-
}
|
|
113
|
-
finally {
|
|
114
|
-
wipeBytes(entropy);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
/** The QR payload for a move. */
|
|
118
|
-
export function buildMoveQrPayload(moveId) {
|
|
119
|
-
return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
|
|
120
|
-
}
|
|
121
|
-
/** The move id in a scanned payload, or `null` when it is not a move QR. */
|
|
122
|
-
export function parseMoveQrPayload(raw) {
|
|
123
|
-
const value = raw.trim();
|
|
124
|
-
if (!value.toLowerCase().startsWith(IDENTITY_MOVE_QR_PREFIX))
|
|
125
|
-
return null;
|
|
126
|
-
const id = value.slice(IDENTITY_MOVE_QR_PREFIX.length).toLowerCase();
|
|
127
|
-
return /^[0-9a-f]{32}$/.test(id) ? id : null;
|
|
128
|
-
}
|