@oxy.so/core 1.16.0 → 1.18.0

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.
Files changed (104) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/identityLink.js +18 -0
  3. package/dist/cjs/i18n/locales/ar-SA.json +78 -3
  4. package/dist/cjs/i18n/locales/ca-ES.json +78 -3
  5. package/dist/cjs/i18n/locales/de-DE.json +78 -3
  6. package/dist/cjs/i18n/locales/en-US.json +75 -28
  7. package/dist/cjs/i18n/locales/es-ES.json +75 -28
  8. package/dist/cjs/i18n/locales/fr-FR.json +78 -3
  9. package/dist/cjs/i18n/locales/it-IT.json +78 -3
  10. package/dist/cjs/i18n/locales/ja-JP.json +78 -3
  11. package/dist/cjs/i18n/locales/ko-KR.json +78 -3
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +78 -3
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +78 -3
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +78 -3
  15. package/dist/cjs/i18n/locales/locales/en-US.json +75 -28
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +75 -28
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +78 -3
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +78 -3
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +78 -3
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +78 -3
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +78 -3
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +78 -3
  23. package/dist/cjs/i18n/locales/pt-PT.json +78 -3
  24. package/dist/cjs/i18n/locales/zh-CN.json +78 -3
  25. package/dist/cjs/index.js +9 -42
  26. package/dist/cjs/mixins/OxyServices.auth.js +54 -16
  27. package/dist/cjs/mixins/OxyServices.identity.js +128 -8
  28. package/dist/cjs/mixins/OxyServices.user.js +42 -7
  29. package/dist/cjs/utils/authWebUrl.js +4 -5
  30. package/dist/esm/.tsbuildinfo +1 -1
  31. package/dist/esm/crypto/identityLink.js +15 -0
  32. package/dist/esm/i18n/locales/ar-SA.json +78 -3
  33. package/dist/esm/i18n/locales/ca-ES.json +78 -3
  34. package/dist/esm/i18n/locales/de-DE.json +78 -3
  35. package/dist/esm/i18n/locales/en-US.json +75 -28
  36. package/dist/esm/i18n/locales/es-ES.json +75 -28
  37. package/dist/esm/i18n/locales/fr-FR.json +78 -3
  38. package/dist/esm/i18n/locales/it-IT.json +78 -3
  39. package/dist/esm/i18n/locales/ja-JP.json +78 -3
  40. package/dist/esm/i18n/locales/ko-KR.json +78 -3
  41. package/dist/esm/i18n/locales/locales/ar-SA.json +78 -3
  42. package/dist/esm/i18n/locales/locales/ca-ES.json +78 -3
  43. package/dist/esm/i18n/locales/locales/de-DE.json +78 -3
  44. package/dist/esm/i18n/locales/locales/en-US.json +75 -28
  45. package/dist/esm/i18n/locales/locales/es-ES.json +75 -28
  46. package/dist/esm/i18n/locales/locales/fr-FR.json +78 -3
  47. package/dist/esm/i18n/locales/locales/it-IT.json +78 -3
  48. package/dist/esm/i18n/locales/locales/ja-JP.json +78 -3
  49. package/dist/esm/i18n/locales/locales/ko-KR.json +78 -3
  50. package/dist/esm/i18n/locales/locales/pt-PT.json +78 -3
  51. package/dist/esm/i18n/locales/locales/zh-CN.json +78 -3
  52. package/dist/esm/i18n/locales/pt-PT.json +78 -3
  53. package/dist/esm/i18n/locales/zh-CN.json +78 -3
  54. package/dist/esm/index.js +4 -4
  55. package/dist/esm/mixins/OxyServices.auth.js +55 -17
  56. package/dist/esm/mixins/OxyServices.identity.js +128 -8
  57. package/dist/esm/mixins/OxyServices.user.js +42 -7
  58. package/dist/esm/utils/authWebUrl.js +4 -5
  59. package/dist/types/.tsbuildinfo +1 -1
  60. package/dist/types/crypto/identityLink.d.ts +8 -0
  61. package/dist/types/index.d.ts +2 -5
  62. package/dist/types/mixins/OxyServices.auth.d.ts +35 -25
  63. package/dist/types/mixins/OxyServices.identity.d.ts +34 -34
  64. package/dist/types/mixins/OxyServices.user.d.ts +29 -6
  65. package/dist/types/models/interfaces.d.ts +46 -13
  66. package/dist/types/server/securityHeaders.d.ts +3 -3
  67. package/dist/types/utils/authWebUrl.d.ts +4 -5
  68. package/package.json +3 -3
  69. package/src/crypto/__tests__/identityLink.test.ts +18 -0
  70. package/src/crypto/__tests__/identityProof.test.ts +6 -6
  71. package/src/crypto/identityLink.ts +18 -0
  72. package/src/i18n/locales/ar-SA.json +78 -3
  73. package/src/i18n/locales/ca-ES.json +78 -3
  74. package/src/i18n/locales/de-DE.json +78 -3
  75. package/src/i18n/locales/en-US.json +75 -28
  76. package/src/i18n/locales/es-ES.json +75 -28
  77. package/src/i18n/locales/fr-FR.json +78 -3
  78. package/src/i18n/locales/it-IT.json +78 -3
  79. package/src/i18n/locales/ja-JP.json +78 -3
  80. package/src/i18n/locales/ko-KR.json +78 -3
  81. package/src/i18n/locales/pt-PT.json +78 -3
  82. package/src/i18n/locales/zh-CN.json +78 -3
  83. package/src/index.ts +6 -48
  84. package/src/mixins/OxyServices.auth.ts +72 -24
  85. package/src/mixins/OxyServices.identity.ts +126 -8
  86. package/src/mixins/OxyServices.user.ts +51 -10
  87. package/src/mixins/__tests__/identityLink.test.ts +89 -0
  88. package/src/mixins/__tests__/notifications.read.test.ts +82 -0
  89. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +1 -1
  90. package/src/mixins/__tests__/webauthnAuth.test.ts +75 -1
  91. package/src/models/interfaces.ts +46 -12
  92. package/src/server/securityHeaders.ts +3 -3
  93. package/src/utils/authWebUrl.ts +4 -5
  94. package/dist/cjs/crypto/identityMove.js +0 -142
  95. package/dist/cjs/crypto/webIdentityCarrier.js +0 -325
  96. package/dist/esm/crypto/identityMove.js +0 -128
  97. package/dist/esm/crypto/webIdentityCarrier.js +0 -303
  98. package/dist/types/crypto/identityMove.d.ts +0 -75
  99. package/dist/types/crypto/webIdentityCarrier.d.ts +0 -150
  100. package/src/crypto/__tests__/identityMove.test.ts +0 -146
  101. package/src/crypto/__tests__/webIdentityCarrier.test.ts +0 -169
  102. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +0 -117
  103. package/src/crypto/identityMove.ts +0 -167
  104. package/src/crypto/webIdentityCarrier.ts +0 -394
@@ -1,146 +0,0 @@
1
- /**
2
- * Giving a root to Commons (ADR 0024 D6): both sides derive the same key and code;
3
- * the responder receives exactly the declared identity; the initiator's key is
4
- * committed before the responder's is chosen, so a relay cannot steer both codes
5
- * together; and the receipt binds the move, root, both keys and the relayed
6
- * ciphertext.
7
- */
8
-
9
- import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
10
- import { signMessage } from '@oxy.so/protocol';
11
- import {
12
- buildMoveQrPayload,
13
- createMoveCommitment,
14
- deriveMoveKey,
15
- deriveMoveSas,
16
- digestMoveCiphertext,
17
- generateMoveEphemeralKeyPair,
18
- openMovedIdentity,
19
- parseMoveQrPayload,
20
- sealIdentityForMove,
21
- signMoveReceipt,
22
- verifyMoveCommitment,
23
- verifyMoveReceipt,
24
- } from '../identityMove';
25
- import { deriveIdentityFromMnemonic, generateWebIdentity } from '../webIdentityCarrier';
26
-
27
- const MOVE_ID = '0123456789abcdef0123456789abcdef';
28
- const MNEMONIC_24 =
29
- 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
30
-
31
- function code(initiator: string, responder: string, commitment: string, moveId = MOVE_ID): string {
32
- return deriveMoveSas({ moveId, initiatorEphemeralPublicKey: initiator, responderEphemeralPublicKey: responder, initiatorCommitment: commitment });
33
- }
34
-
35
- describe('a move between two honest sides', () => {
36
- it('delivers the declared identity — 12 or 24 words — and both screens show the same code', () => {
37
- for (const identity of [generateWebIdentity(), deriveIdentityFromMnemonic(MNEMONIC_24)]) {
38
- const web = generateMoveEphemeralKeyPair();
39
- const commons = generateMoveEphemeralKeyPair();
40
- const { commitment, nonce } = createMoveCommitment(web.publicKey);
41
- expect(verifyMoveCommitment(web.publicKey, nonce, commitment)).toBe(true);
42
-
43
- const webKey = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
44
- const commonsKey = deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID);
45
- expect(Buffer.from(webKey).equals(Buffer.from(commonsKey))).toBe(true);
46
- expect(code(web.publicKey, commons.publicKey, commitment)).toMatch(/^\d{6}$/);
47
- expect(code(web.publicKey, commons.publicKey, commitment, MOVE_ID.toUpperCase())).toBe(code(web.publicKey, commons.publicKey, commitment));
48
-
49
- const sealed = sealIdentityForMove(identity, webKey, MOVE_ID);
50
- expect(JSON.stringify(sealed)).not.toContain(identity.privateKey);
51
- expect(openMovedIdentity(sealed, commonsKey, MOVE_ID, identity.publicKey)).toEqual(identity);
52
- }
53
- });
54
- });
55
-
56
- describe('a relay that substitutes keys', () => {
57
- it('cannot reveal a key the commitment does not open', () => {
58
- const web = generateSecp256k1KeyPair();
59
- const { commitment, nonce } = createMoveCommitment(web.publicKey);
60
- expect(verifyMoveCommitment(generateSecp256k1KeyPair().publicKey, nonce, commitment)).toBe(false);
61
- expect(verifyMoveCommitment(web.publicKey, 'ab'.repeat(32), commitment)).toBe(false);
62
- });
63
-
64
- it('shows a different code on each screen and cannot open the identity', () => {
65
- const identity = generateWebIdentity();
66
- const web = generateMoveEphemeralKeyPair();
67
- const commons = generateMoveEphemeralKeyPair();
68
- const attacker = generateMoveEphemeralKeyPair();
69
- const { commitment } = createMoveCommitment(web.publicKey);
70
- const attackerCommitment = createMoveCommitment(attacker.publicKey).commitment;
71
-
72
- expect(code(web.publicKey, attacker.publicKey, commitment)).not.toBe(code(attacker.publicKey, commons.publicKey, attackerCommitment));
73
-
74
- const sealedToAttacker = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, attacker.publicKey, MOVE_ID), MOVE_ID);
75
- expect(() =>
76
- openMovedIdentity(sealedToAttacker, deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID), MOVE_ID, identity.publicKey),
77
- ).toThrow('could not be opened');
78
- });
79
-
80
- it('changes the code when any input changes, including the commitment', () => {
81
- const web = generateSecp256k1KeyPair();
82
- const commons = generateSecp256k1KeyPair();
83
- const { commitment } = createMoveCommitment(web.publicKey);
84
- const base = code(web.publicKey, commons.publicKey, commitment);
85
- expect(code(web.publicKey, commons.publicKey, createMoveCommitment(web.publicKey).commitment)).not.toBe(base);
86
- expect(code(web.publicKey, generateSecp256k1KeyPair().publicKey, commitment)).not.toBe(base);
87
- expect(code(commons.publicKey, web.publicKey, commitment)).not.toBe(base);
88
- });
89
-
90
- it('refuses a sealed identity that is not the one the move declared, or another move’s ciphertext', () => {
91
- const declared = generateWebIdentity();
92
- const web = generateMoveEphemeralKeyPair();
93
- const commons = generateMoveEphemeralKeyPair();
94
- const key = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
95
- expect(() => openMovedIdentity(sealIdentityForMove(generateWebIdentity(), key, MOVE_ID), key, MOVE_ID, declared.publicKey)).toThrow();
96
- expect(() => openMovedIdentity(sealIdentityForMove(declared, key, MOVE_ID), key, 'ffffffffffffffffffffffffffffffff', declared.publicKey)).toThrow();
97
- });
98
- });
99
-
100
- describe('receipts', () => {
101
- function transfer() {
102
- const identity = generateWebIdentity();
103
- const web = generateMoveEphemeralKeyPair();
104
- const commons = generateMoveEphemeralKeyPair();
105
- const sealed = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID), MOVE_ID);
106
- const claims = {
107
- moveId: MOVE_ID,
108
- rootPublicKey: identity.publicKey,
109
- initiatorEphemeralPublicKey: web.publicKey,
110
- responderEphemeralPublicKey: commons.publicKey,
111
- ciphertextDigest: digestMoveCiphertext(sealed),
112
- };
113
- return { identity, sealed, claims };
114
- }
115
-
116
- it('verify for exactly the move, root, keys and ciphertext they were made over', async () => {
117
- const { identity, sealed, claims } = transfer();
118
- const receipt = await signMoveReceipt((message) => signMessage(message, identity.privateKey), claims);
119
- expect(await verifyMoveReceipt(claims, receipt.signature)).toBe(true);
120
-
121
- const other = { ...sealed, ciphertext: `${sealed.ciphertext.slice(0, -2)}00` };
122
- for (const changed of [
123
- { ...claims, moveId: 'ffffffffffffffffffffffffffffffff' },
124
- { ...claims, responderEphemeralPublicKey: generateSecp256k1KeyPair().publicKey },
125
- { ...claims, initiatorEphemeralPublicKey: generateSecp256k1KeyPair().publicKey },
126
- { ...claims, ciphertextDigest: digestMoveCiphertext(other) },
127
- ]) {
128
- expect(await verifyMoveReceipt(changed, receipt.signature)).toBe(false);
129
- }
130
- });
131
-
132
- it('are not the root’s if another key signed them', async () => {
133
- const { claims } = transfer();
134
- const forged = await signMoveReceipt((message) => signMessage(message, generateWebIdentity().privateKey), claims);
135
- expect(await verifyMoveReceipt(claims, forged.signature)).toBe(false);
136
- });
137
- });
138
-
139
- describe('QR payload', () => {
140
- it('round-trips and rejects anything else', () => {
141
- expect(parseMoveQrPayload(buildMoveQrPayload(MOVE_ID))).toBe(MOVE_ID);
142
- expect(parseMoveQrPayload(` ${buildMoveQrPayload(MOVE_ID).toUpperCase()} `)).toBe(MOVE_ID);
143
- expect(parseMoveQrPayload('oxycommons://approve?code=abc')).toBeNull();
144
- expect(parseMoveQrPayload('oxycommons://move?id=123')).toBeNull();
145
- });
146
- });
@@ -1,169 +0,0 @@
1
- /**
2
- * Web identity carrier — the envelope must carry EXACTLY the Commons identity,
3
- * open only with a registered passkey's PRF output, and refuse (with a reason a
4
- * UI can act on) anything else.
5
- */
6
-
7
- import { webIdentityEnvelopeSchema } from '@oxy.so/contracts';
8
- import { RecoveryPhraseService } from '../recoveryPhrase';
9
- import {
10
- addWrap,
11
- deriveIdentityFromMnemonic,
12
- generateWebIdentity,
13
- openWebIdentity,
14
- removeWrap,
15
- sealWebIdentity,
16
- unlockWebIdentity,
17
- unwrapDataKey,
18
- WEB_IDENTITY_PRF_INPUT,
19
- WebIdentityUnlockError,
20
- wipeBytes,
21
- } from '../webIdentityCarrier';
22
-
23
- /** The BIP-39 reference mnemonic (all-zero entropy). */
24
- const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
25
-
26
- const prf = (fill: number): Uint8Array => new Uint8Array(32).fill(fill);
27
- const CREDENTIAL_A = 'credential-aaaaaaaaaaaaaaaa';
28
- const CREDENTIAL_B = 'credential-bbbbbbbbbbbbbbbb';
29
-
30
- const failureOf = (fn: () => unknown): string | undefined => {
31
- try {
32
- fn();
33
- } catch (error) {
34
- return error instanceof WebIdentityUnlockError ? error.failure : 'other';
35
- }
36
- return undefined;
37
- };
38
-
39
- describe('the identity itself', () => {
40
- it('derives the SAME key Commons derives from a phrase — one identity, not a second kind', async () => {
41
- const identity = deriveIdentityFromMnemonic(MNEMONIC);
42
-
43
- expect(identity.privateKey).toBe(await RecoveryPhraseService.derivePrivateKeyFromPhrase(MNEMONIC));
44
- expect(identity.publicKey).toBe(await RecoveryPhraseService.derivePublicKeyFromPhrase(MNEMONIC));
45
- });
46
-
47
- it('normalizes case and spacing the way a person types a phrase', () => {
48
- const typed = ` ${MNEMONIC.toUpperCase().split(' ').join(' ')} `;
49
- expect(deriveIdentityFromMnemonic(typed).publicKey).toBe(deriveIdentityFromMnemonic(MNEMONIC).publicKey);
50
- });
51
-
52
- it('rejects an invalid phrase', () => {
53
- expect(() => deriveIdentityFromMnemonic('abandon abandon abandon')).toThrow('Invalid recovery phrase');
54
- });
55
-
56
- it('generates a fresh 12-word identity in canonical public-key form', () => {
57
- const identity = generateWebIdentity();
58
- expect(identity.mnemonic.split(' ')).toHaveLength(12);
59
- expect(identity.publicKey).toMatch(/^04[0-9a-f]{128}$/);
60
- expect(generateWebIdentity().publicKey).not.toBe(identity.publicKey);
61
- });
62
-
63
- it('uses a fixed 32-byte PRF input', () => {
64
- expect(WEB_IDENTITY_PRF_INPUT).toHaveLength(32);
65
- });
66
- });
67
-
68
- describe('sealing and unlocking', () => {
69
- it('round-trips: the passkey that sealed it opens it into the same identity', () => {
70
- const identity = deriveIdentityFromMnemonic(MNEMONIC);
71
- const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
72
- wipeBytes(dataKey);
73
-
74
- // The stored shape is exactly the published contract.
75
- expect(webIdentityEnvelopeSchema.parse(envelope)).toEqual(envelope);
76
- // Nothing identifying the secret is visible in the envelope.
77
- expect(JSON.stringify(envelope)).not.toContain(identity.privateKey);
78
-
79
- const opened = unlockWebIdentity(envelope, prf(7), CREDENTIAL_A);
80
- expect(opened).toEqual(identity);
81
- });
82
-
83
- it('refuses to seal a phrase under a public key it does not belong to', () => {
84
- const other = generateWebIdentity();
85
- expect(() =>
86
- sealWebIdentity({ ...deriveIdentityFromMnemonic(MNEMONIC), publicKey: other.publicKey }, { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' }),
87
- ).toThrow('does not belong');
88
- });
89
-
90
- it('reports an unregistered passkey as unknown-credential', () => {
91
- const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
92
- expect(failureOf(() => unlockWebIdentity(envelope, prf(7), CREDENTIAL_B))).toBe('unknown-credential');
93
- });
94
-
95
- it('reports a registered passkey returning a different PRF value as prf-mismatch (recoverable)', () => {
96
- const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
97
- expect(failureOf(() => unlockWebIdentity(envelope, prf(8), CREDENTIAL_A))).toBe('prf-mismatch');
98
- });
99
-
100
- it('refuses a wrap transplanted onto another credential id', () => {
101
- const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
102
- const transplanted = { ...envelope, wraps: [{ ...envelope.wraps[0], credentialId: CREDENTIAL_B }] };
103
- // The associated data binds each wrap to its credential.
104
- expect(failureOf(() => unlockWebIdentity(transplanted, prf(7), CREDENTIAL_B))).toBe('prf-mismatch');
105
- });
106
-
107
- it('refuses an envelope re-labelled with another identity public key', () => {
108
- const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), {
109
- prfOutput: prf(7),
110
- credentialId: CREDENTIAL_A,
111
- rpId: 'oxy.so',
112
- });
113
- const relabelled = { ...envelope, publicKey: generateWebIdentity().publicKey };
114
- expect(failureOf(() => openWebIdentity(relabelled, dataKey))).toBe('corrupt');
115
- });
116
-
117
- it('treats a tampered sealed secret as corrupt, never as a different identity', () => {
118
- const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), {
119
- prfOutput: prf(7),
120
- credentialId: CREDENTIAL_A,
121
- rpId: 'oxy.so',
122
- });
123
- const flipped = envelope.sealedSecret.startsWith('0') ? `1${envelope.sealedSecret.slice(1)}` : `0${envelope.sealedSecret.slice(1)}`;
124
- expect(failureOf(() => openWebIdentity({ ...envelope, sealedSecret: flipped }, dataKey))).toBe('corrupt');
125
- });
126
- });
127
-
128
- describe('more than one passkey', () => {
129
- it('opens with every registered passkey and keeps a single sealed identity', () => {
130
- const identity = deriveIdentityFromMnemonic(MNEMONIC);
131
- const sealed = sealWebIdentity(identity, { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
132
- const withTwo = addWrap(sealed.envelope, sealed.dataKey, { prfOutput: prf(2), credentialId: CREDENTIAL_B, rpId: 'oxy.so' });
133
-
134
- expect(withTwo.wraps.map((wrap) => wrap.credentialId)).toEqual([CREDENTIAL_A, CREDENTIAL_B]);
135
- expect(withTwo.sealedSecret).toBe(sealed.envelope.sealedSecret);
136
- expect(unlockWebIdentity(withTwo, prf(1), CREDENTIAL_A).publicKey).toBe(identity.publicKey);
137
- expect(unlockWebIdentity(withTwo, prf(2), CREDENTIAL_B).publicKey).toBe(identity.publicKey);
138
- });
139
-
140
- it('replaces a passkey’s wrap instead of duplicating it', () => {
141
- const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
142
- const rewrapped = addWrap(sealed.envelope, sealed.dataKey, { prfOutput: prf(9), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
143
- expect(rewrapped.wraps).toHaveLength(1);
144
- expect(failureOf(() => unlockWebIdentity(rewrapped, prf(1), CREDENTIAL_A))).toBe('prf-mismatch');
145
- expect(unlockWebIdentity(rewrapped, prf(9), CREDENTIAL_A).mnemonic).toBe(MNEMONIC);
146
- });
147
-
148
- it('will not add a wrap with a data key from a different envelope', () => {
149
- const first = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
150
- const second = sealWebIdentity(generateWebIdentity(), { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
151
- expect(() => addWrap(first.envelope, second.dataKey, { prfOutput: prf(2), credentialId: CREDENTIAL_B, rpId: 'oxy.so' })).toThrow();
152
- });
153
-
154
- it('removes a passkey, but never the last one', () => {
155
- const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
156
- const withTwo = addWrap(sealed.envelope, sealed.dataKey, { prfOutput: prf(2), credentialId: CREDENTIAL_B, rpId: 'oxy.so' });
157
- const withOne = removeWrap(withTwo, CREDENTIAL_A);
158
-
159
- expect(withOne.wraps.map((wrap) => wrap.credentialId)).toEqual([CREDENTIAL_B]);
160
- expect(() => removeWrap(withOne, CREDENTIAL_B)).toThrow('at least one passkey');
161
- });
162
-
163
- it('keeps the data key usable only until it is wiped', () => {
164
- const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
165
- const recovered = unwrapDataKey(sealed.envelope, prf(1), CREDENTIAL_A);
166
- wipeBytes(recovered);
167
- expect(failureOf(() => openWebIdentity(sealed.envelope, recovered))).toBe('corrupt');
168
- });
169
- });
@@ -1,117 +0,0 @@
1
- /**
2
- * Envelopes (ADR 0024 D2, D5): one root, whatever material it came from — a 12-
3
- * or 24-word phrase, or a raw key that must never gain a phrase — and wraps bound
4
- * to the RP ID their passkey lives under.
5
- */
6
-
7
- import { webIdentityEnvelopeSchema } from '@oxy.so/contracts';
8
- import { mnemonicToSeedSync } from '@scure/bip39';
9
- import { bytesToHex } from '@noble/hashes/utils';
10
- import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
11
- import {
12
- addWrap,
13
- deriveIdentityFromMnemonic,
14
- deriveIdentityFromPrivateKey,
15
- deriveIdentityFromRecoveryMaterial,
16
- isUsablePrfOutput,
17
- markWrapVerified,
18
- parseRecoveryMaterial,
19
- sealWebIdentity,
20
- unlockWebIdentity,
21
- unwrapDataKey,
22
- WebIdentityUnlockError,
23
- wipeBytes,
24
- } from '../webIdentityCarrier';
25
-
26
- const MNEMONIC_12 = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
27
- /** BIP-39 reference vector: 32 bytes of zero entropy. */
28
- const MNEMONIC_24 =
29
- 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
30
- const RAW_KEY = '1f'.repeat(32);
31
- const prf = (fill: number): Uint8Array => new Uint8Array(32).fill(fill);
32
- const CREDENTIAL = 'credential-aaaaaaaaaaaaaaaa';
33
-
34
- const failureOf = (fn: () => unknown): string | undefined => {
35
- try {
36
- fn();
37
- } catch (error) {
38
- return error instanceof WebIdentityUnlockError ? error.failure : 'other';
39
- }
40
- return undefined;
41
- };
42
-
43
- describe('recovery material', () => {
44
- it('derives a 24-word root exactly as seed[0:32], unchanged from the 12-word rule', () => {
45
- const identity = deriveIdentityFromMnemonic(MNEMONIC_24);
46
- const expected = bytesToHex(mnemonicToSeedSync(MNEMONIC_24).slice(0, 32));
47
- expect(identity.kind).toBe('mnemonic');
48
- expect(identity.privateKey).toBe(expected);
49
- expect(identity.publicKey).toBe(deriveSecp256k1PublicKey(expected));
50
- });
51
-
52
- it('reads a phrase or a raw key, and nothing else', () => {
53
- expect(parseRecoveryMaterial(` ${MNEMONIC_24.toUpperCase()} `)).toEqual({ kind: 'mnemonic', mnemonic: MNEMONIC_24 });
54
- expect(parseRecoveryMaterial(`0x${RAW_KEY.toUpperCase()}`)).toEqual({ kind: 'raw-key', privateKey: `0x${RAW_KEY.toUpperCase()}` });
55
- expect(() => parseRecoveryMaterial('abandon abandon')).toThrow('Invalid recovery material');
56
- expect(() => parseRecoveryMaterial('zz'.repeat(32))).toThrow('Invalid recovery material');
57
- });
58
-
59
- it('keeps a raw key a raw key: no phrase is ever derived for it', () => {
60
- const identity = deriveIdentityFromRecoveryMaterial({ kind: 'raw-key', privateKey: `0x${RAW_KEY}` });
61
- expect(identity).toEqual({ kind: 'raw-key', mnemonic: null, privateKey: RAW_KEY, publicKey: deriveSecp256k1PublicKey(RAW_KEY) });
62
- expect(() => deriveIdentityFromPrivateKey('00'.repeat(32))).toThrow('Invalid private key');
63
- });
64
-
65
- it('treats only an actual 32-byte PRF output as usable', () => {
66
- expect(isUsablePrfOutput(prf(1))).toBe(true);
67
- expect(isUsablePrfOutput(new Uint8Array(31))).toBe(false);
68
- expect(isUsablePrfOutput(null)).toBe(false);
69
- });
70
- });
71
-
72
- describe('envelopes', () => {
73
- it.each([
74
- ['12 words', () => deriveIdentityFromMnemonic(MNEMONIC_12)],
75
- ['24 words', () => deriveIdentityFromMnemonic(MNEMONIC_24)],
76
- ['a raw key', () => deriveIdentityFromPrivateKey(RAW_KEY)],
77
- ])('seals and reopens the same root from %s', (_label, make) => {
78
- const identity = make();
79
- const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(7), credentialId: CREDENTIAL, rpId: 'oxy.so' });
80
- wipeBytes(dataKey);
81
-
82
- expect(webIdentityEnvelopeSchema.safeParse(envelope).success).toBe(true);
83
- expect(envelope.version).toBe(2);
84
- expect(envelope.wraps[0].rpId).toBe('oxy.so');
85
- const opened = unlockWebIdentity(envelope, prf(7), CREDENTIAL);
86
- expect(opened).toEqual(make());
87
- });
88
-
89
- it('binds the RP ID into the wrap, so a relabelled wrap does not open', () => {
90
- const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC_12), { prfOutput: prf(3), credentialId: CREDENTIAL, rpId: 'oxy.so' });
91
- wipeBytes(dataKey);
92
- const relabelled = { ...envelope, wraps: envelope.wraps.map((wrap) => ({ ...wrap, rpId: 'auth.oxy.so' })) };
93
- expect(failureOf(() => unwrapDataKey(relabelled, prf(3), CREDENTIAL))).toBe('prf-mismatch');
94
- });
95
-
96
- it('refuses a secret kind that was swapped after sealing', () => {
97
- const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC_24), { prfOutput: prf(3), credentialId: CREDENTIAL });
98
- const swapped = { ...envelope, secretKind: 'raw-private-key' as const };
99
- expect(failureOf(() => unlockWebIdentity(swapped, prf(3), CREDENTIAL))).toBe('corrupt');
100
- wipeBytes(dataKey);
101
- });
102
-
103
- it('adds a second passkey and records which wraps have proven they open the root', () => {
104
- const identity = deriveIdentityFromMnemonic(MNEMONIC_12);
105
- const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(1), credentialId: CREDENTIAL, rpId: 'oxy.so' });
106
- const second = 'credential-bbbbbbbbbbbbbbbb';
107
- const both = addWrap(envelope, dataKey, { prfOutput: prf(2), credentialId: second, rpId: 'oxy.so' });
108
- wipeBytes(dataKey);
109
-
110
- expect(unlockWebIdentity(both, prf(2), second).publicKey).toBe(identity.publicKey);
111
- const verified = markWrapVerified(both, second, new Date('2026-09-16T00:00:00.000Z'));
112
- expect(verified.wraps.find((wrap) => wrap.credentialId === second)?.verifiedAt).toBe('2026-09-16T00:00:00.000Z');
113
- expect(verified.wraps.find((wrap) => wrap.credentialId === CREDENTIAL)?.verifiedAt).toBeUndefined();
114
- // Holder metadata is not part of the AEAD: marking does not change what opens.
115
- expect(unlockWebIdentity(verified, prf(1), CREDENTIAL).publicKey).toBe(identity.publicKey);
116
- });
117
- });
@@ -1,167 +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
-
15
- import './polyfill';
16
- import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
17
- import { wordlist } from '@scure/bip39/wordlists/english';
18
- import { sha256 } from '@noble/hashes/sha256';
19
- import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
20
- import { verifySignature } from '@oxy.so/protocol';
21
- import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
22
- import {
23
- IDENTITY_MOVE_QR_PREFIX,
24
- buildMoveCiphertextDigestInput,
25
- buildMoveCommitmentInput,
26
- buildMoveReceiptMessage,
27
- buildMoveSasInput,
28
- } from '@oxy.so/contracts';
29
- import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead';
30
- import { deriveSharedSecret } from './ecdh';
31
- import { hkdfSha256 } from './kdf';
32
- import { deriveIdentityFromMnemonic, wipeBytes, type OpenedMnemonicIdentity } from './webIdentityCarrier';
33
-
34
- const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
35
-
36
- function sha256Hex(input: string): string {
37
- return bytesToHex(sha256(utf8ToBytes(input)));
38
- }
39
-
40
- /** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
41
- export function generateMoveEphemeralKeyPair(): { privateKey: string; publicKey: string } {
42
- return generateSecp256k1KeyPair();
43
- }
44
-
45
- /** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
46
- export function deriveMoveKey(ownEphemeralPrivateKey: string, otherEphemeralPublicKey: string, moveId: string): Uint8Array {
47
- const shared = deriveSharedSecret(ownEphemeralPrivateKey, otherEphemeralPublicKey);
48
- try {
49
- return hkdfSha256(shared, utf8ToBytes(moveId.toLowerCase()), MOVE_KDF_INFO, AEAD_KEY_LENGTH);
50
- } finally {
51
- wipeBytes(shared);
52
- }
53
- }
54
-
55
- /** Initiator: commit to the ephemeral key before anyone else's key is known. The nonce stays in memory until the reveal. */
56
- export function createMoveCommitment(initiatorEphemeralPublicKey: string): { commitment: string; nonce: string } {
57
- const nonceBytes = new Uint8Array(32);
58
- globalThis.crypto.getRandomValues(nonceBytes);
59
- const nonce = bytesToHex(nonceBytes);
60
- return { commitment: sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)), nonce };
61
- }
62
-
63
- /** Responder: does the revealed key match the commitment read before joining? */
64
- export function verifyMoveCommitment(initiatorEphemeralPublicKey: string, nonce: string, commitment: string): boolean {
65
- return sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)) === commitment.toLowerCase();
66
- }
67
-
68
- /** The 6-digit code, bound to the move, both keys in their roles, and the commitment. */
69
- export function deriveMoveSas(input: {
70
- moveId: string;
71
- initiatorEphemeralPublicKey: string;
72
- responderEphemeralPublicKey: string;
73
- initiatorCommitment: string;
74
- }): string {
75
- const digest = sha256(utf8ToBytes(buildMoveSasInput(input)));
76
- const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
77
- return String(value % 1_000_000).padStart(6, '0');
78
- }
79
-
80
- /** The digest a receipt binds: what was actually relayed. */
81
- export function digestMoveCiphertext(sealed: { nonce: string; ciphertext: string }): string {
82
- return sha256Hex(buildMoveCiphertextDigestInput(sealed));
83
- }
84
-
85
- export interface MoveReceiptClaims {
86
- moveId: string;
87
- rootPublicKey: string;
88
- initiatorEphemeralPublicKey: string;
89
- responderEphemeralPublicKey: string;
90
- ciphertextDigest: string;
91
- }
92
-
93
- /**
94
- * Responder: sign the receipt with a signer that reads the root back from durable
95
- * storage (Commons passes its keychain signer), so a receipt exists only for a
96
- * root that was actually stored.
97
- */
98
- export async function signMoveReceipt(sign: (message: string) => Promise<string>, claims: MoveReceiptClaims): Promise<{ signature: string }> {
99
- return { signature: await sign(buildMoveReceiptMessage(claims)) };
100
- }
101
-
102
- /** Initiator: is this receipt the root's, for exactly this move and ciphertext? */
103
- export function verifyMoveReceipt(claims: MoveReceiptClaims, signature: string): Promise<boolean> {
104
- return verifySignature(buildMoveReceiptMessage(claims), signature, claims.rootPublicKey);
105
- }
106
-
107
- function moveAad(moveId: string, publicKey: string): Uint8Array {
108
- return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
109
- }
110
-
111
- /** Initiator: seal the phrase entropy (12–24 words) for the responder. */
112
- export function sealIdentityForMove(
113
- identity: Pick<OpenedMnemonicIdentity, 'mnemonic' | 'publicKey'>,
114
- moveKey: Uint8Array,
115
- moveId: string,
116
- ): { nonce: string; ciphertext: string } {
117
- if (typeof identity.mnemonic !== 'string' || !identity.mnemonic) {
118
- throw new Error('Only an identity with a recovery phrase can be moved this way');
119
- }
120
- const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
121
- try {
122
- const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
123
- return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(ciphertext) };
124
- } finally {
125
- wipeBytes(entropy);
126
- }
127
- }
128
-
129
- /**
130
- * Responder: open the sealed identity. The derived public key must be the one
131
- * the move declared — anything else is refused, never imported.
132
- */
133
- export function openMovedIdentity(
134
- sealed: { nonce: string; ciphertext: string },
135
- moveKey: Uint8Array,
136
- moveId: string,
137
- expectedPublicKey: string,
138
- ): OpenedMnemonicIdentity {
139
- let entropy: Uint8Array;
140
- try {
141
- entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
142
- } catch {
143
- throw new Error('The identity could not be opened. The codes may not have matched — start again.');
144
- }
145
- try {
146
- const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
147
- if (identity.publicKey !== expectedPublicKey.toLowerCase()) {
148
- throw new Error('The received identity is not the one being moved');
149
- }
150
- return identity;
151
- } finally {
152
- wipeBytes(entropy);
153
- }
154
- }
155
-
156
- /** The QR payload for a move. */
157
- export function buildMoveQrPayload(moveId: string): string {
158
- return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
159
- }
160
-
161
- /** The move id in a scanned payload, or `null` when it is not a move QR. */
162
- export function parseMoveQrPayload(raw: string): string | null {
163
- const value = raw.trim();
164
- if (!value.toLowerCase().startsWith(IDENTITY_MOVE_QR_PREFIX)) return null;
165
- const id = value.slice(IDENTITY_MOVE_QR_PREFIX.length).toLowerCase();
166
- return /^[0-9a-f]{32}$/.test(id) ? id : null;
167
- }