@oxy.so/core 1.2.1 → 1.3.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 (100) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/identityMove.js +123 -0
  3. package/dist/cjs/crypto/keyManager.js +60 -21
  4. package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
  5. package/dist/cjs/i18n/locales/ar-SA.json +16 -13
  6. package/dist/cjs/i18n/locales/ca-ES.json +16 -13
  7. package/dist/cjs/i18n/locales/de-DE.json +16 -13
  8. package/dist/cjs/i18n/locales/en-US.json +16 -17
  9. package/dist/cjs/i18n/locales/es-ES.json +16 -17
  10. package/dist/cjs/i18n/locales/fr-FR.json +16 -13
  11. package/dist/cjs/i18n/locales/it-IT.json +16 -13
  12. package/dist/cjs/i18n/locales/ja-JP.json +16 -13
  13. package/dist/cjs/i18n/locales/ko-KR.json +16 -13
  14. package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
  15. package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
  16. package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
  17. package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
  18. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
  19. package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
  20. package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
  21. package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
  22. package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
  23. package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
  24. package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
  25. package/dist/cjs/i18n/locales/pt-PT.json +16 -13
  26. package/dist/cjs/i18n/locales/zh-CN.json +16 -13
  27. package/dist/cjs/index.js +38 -4
  28. package/dist/cjs/mixins/OxyServices.auth.js +14 -4
  29. package/dist/cjs/mixins/OxyServices.identity.js +14 -0
  30. package/dist/cjs/session/accountDialogController.js +333 -142
  31. package/dist/cjs/utils/authWebUrl.js +8 -1
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/crypto/identityMove.js +110 -0
  34. package/dist/esm/crypto/keyManager.js +60 -21
  35. package/dist/esm/crypto/webIdentityCarrier.js +243 -0
  36. package/dist/esm/i18n/locales/ar-SA.json +16 -13
  37. package/dist/esm/i18n/locales/ca-ES.json +16 -13
  38. package/dist/esm/i18n/locales/de-DE.json +16 -13
  39. package/dist/esm/i18n/locales/en-US.json +16 -17
  40. package/dist/esm/i18n/locales/es-ES.json +16 -17
  41. package/dist/esm/i18n/locales/fr-FR.json +16 -13
  42. package/dist/esm/i18n/locales/it-IT.json +16 -13
  43. package/dist/esm/i18n/locales/ja-JP.json +16 -13
  44. package/dist/esm/i18n/locales/ko-KR.json +16 -13
  45. package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
  46. package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
  47. package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
  48. package/dist/esm/i18n/locales/locales/en-US.json +16 -17
  49. package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
  50. package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
  51. package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
  52. package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
  53. package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
  54. package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
  55. package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
  56. package/dist/esm/i18n/locales/pt-PT.json +16 -13
  57. package/dist/esm/i18n/locales/zh-CN.json +16 -13
  58. package/dist/esm/index.js +5 -1
  59. package/dist/esm/mixins/OxyServices.auth.js +14 -4
  60. package/dist/esm/mixins/OxyServices.identity.js +14 -0
  61. package/dist/esm/session/accountDialogController.js +334 -143
  62. package/dist/esm/utils/authWebUrl.js +7 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/crypto/identityMove.d.ts +59 -0
  65. package/dist/types/crypto/keyManager.d.ts +35 -5
  66. package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
  67. package/dist/types/index.d.ts +5 -2
  68. package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
  69. package/dist/types/models/interfaces.d.ts +15 -0
  70. package/dist/types/session/accountDialogController.d.ts +169 -17
  71. package/dist/types/utils/authWebUrl.d.ts +7 -0
  72. package/package.json +2 -2
  73. package/src/crypto/__tests__/identityMove.test.ts +104 -0
  74. package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
  75. package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
  76. package/src/crypto/identityMove.ts +133 -0
  77. package/src/crypto/keyManager.ts +71 -21
  78. package/src/crypto/webIdentityCarrier.ts +326 -0
  79. package/src/i18n/locales/ar-SA.json +16 -13
  80. package/src/i18n/locales/ca-ES.json +16 -13
  81. package/src/i18n/locales/de-DE.json +16 -13
  82. package/src/i18n/locales/en-US.json +16 -17
  83. package/src/i18n/locales/es-ES.json +16 -17
  84. package/src/i18n/locales/fr-FR.json +16 -13
  85. package/src/i18n/locales/it-IT.json +16 -13
  86. package/src/i18n/locales/ja-JP.json +16 -13
  87. package/src/i18n/locales/ko-KR.json +16 -13
  88. package/src/i18n/locales/pt-PT.json +16 -13
  89. package/src/i18n/locales/zh-CN.json +16 -13
  90. package/src/index.ts +38 -1
  91. package/src/mixins/OxyServices.auth.ts +16 -3
  92. package/src/mixins/OxyServices.identity.ts +14 -0
  93. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
  94. package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
  95. package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
  96. package/src/models/interfaces.ts +15 -0
  97. package/src/session/__tests__/accountDialogController.test.ts +407 -10
  98. package/src/session/accountDialogController.ts +406 -145
  99. package/src/utils/__tests__/oauthPkce.test.ts +50 -26
  100. package/src/utils/authWebUrl.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -86,7 +86,7 @@
86
86
  "node": ">=18.0.0"
87
87
  },
88
88
  "scripts": {
89
- "build": "bun run --filter @oxy.so/telemetry build && bun run build:cjs && bun run build:esm && bun run build:types && bun run copy-assets && node scripts/mark-module-formats.mjs",
89
+ "build": "node scripts/build-workspace-deps.mjs @oxy.so/telemetry && bun run build:cjs && bun run build:esm && bun run build:types && bun run copy-assets && node scripts/mark-module-formats.mjs",
90
90
  "copy-assets": "cp -r src/i18n/locales dist/cjs/i18n/locales && cp -r src/i18n/locales dist/esm/i18n/locales",
91
91
  "build:cjs": "tsc -p tsconfig.cjs.json",
92
92
  "build:esm": "tsc -p tsconfig.esm.json && node scripts/fix-esm-imports.mjs",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Moving an identity: both sides derive the same key and code; the responder
3
+ * receives exactly the identity that was declared; a relay that swaps a key
4
+ * gets a different code and cannot open the identity; receipts cannot be forged.
5
+ */
6
+
7
+ import {
8
+ buildMoveQrPayload,
9
+ deriveMoveKey,
10
+ deriveMoveSas,
11
+ generateMoveEphemeralKeyPair,
12
+ IDENTITY_MOVE_ACTIONS,
13
+ openMovedIdentity,
14
+ parseMoveQrPayload,
15
+ sealIdentityForMove,
16
+ signMoveAction,
17
+ verifyMoveReceipt,
18
+ } from '../identityMove';
19
+ import { generateWebIdentity } from '../webIdentityCarrier';
20
+
21
+ const MOVE_ID = '0123456789abcdef0123456789abcdef';
22
+
23
+ describe('a move between two honest carriers', () => {
24
+ it('delivers the declared identity and both screens show the same code', () => {
25
+ const identity = generateWebIdentity();
26
+ const web = generateMoveEphemeralKeyPair();
27
+ const commons = generateMoveEphemeralKeyPair();
28
+
29
+ const webKey = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
30
+ const commonsKey = deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID);
31
+ expect(Buffer.from(webKey).equals(Buffer.from(commonsKey))).toBe(true);
32
+
33
+ expect(deriveMoveSas(MOVE_ID, web.publicKey, commons.publicKey)).toMatch(/^\d{6}$/);
34
+
35
+ const sealed = sealIdentityForMove(identity, webKey, MOVE_ID);
36
+ expect(JSON.stringify(sealed)).not.toContain(identity.privateKey);
37
+
38
+ const received = openMovedIdentity(sealed, commonsKey, MOVE_ID, identity.publicKey);
39
+ expect(received).toEqual(identity);
40
+ });
41
+ });
42
+
43
+ describe('a relay that substitutes a key', () => {
44
+ it('shows a different code on each screen and cannot open the identity', () => {
45
+ const identity = generateWebIdentity();
46
+ const web = generateMoveEphemeralKeyPair();
47
+ const commons = generateMoveEphemeralKeyPair();
48
+ const attacker = generateMoveEphemeralKeyPair();
49
+
50
+ // Web talks to the attacker thinking it is Commons; Commons talks to the attacker thinking it is the web.
51
+ const webSas = deriveMoveSas(MOVE_ID, web.publicKey, attacker.publicKey);
52
+ const commonsSas = deriveMoveSas(MOVE_ID, attacker.publicKey, commons.publicKey);
53
+ expect(webSas).not.toBe(commonsSas);
54
+
55
+ // Even sealed to the attacker, Commons' own key cannot open it.
56
+ const sealedToAttacker = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, attacker.publicKey, MOVE_ID), MOVE_ID);
57
+ expect(() =>
58
+ openMovedIdentity(sealedToAttacker, deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID), MOVE_ID, identity.publicKey),
59
+ ).toThrow('could not be opened');
60
+ });
61
+
62
+ it('refuses a sealed identity that is not the one the move declared', () => {
63
+ const declared = generateWebIdentity();
64
+ const other = generateWebIdentity();
65
+ const web = generateMoveEphemeralKeyPair();
66
+ const commons = generateMoveEphemeralKeyPair();
67
+ const key = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
68
+
69
+ const sealedOther = sealIdentityForMove(other, key, MOVE_ID);
70
+ expect(() => openMovedIdentity(sealedOther, key, MOVE_ID, declared.publicKey)).toThrow();
71
+ });
72
+
73
+ it('binds the ciphertext to its move id', () => {
74
+ const identity = generateWebIdentity();
75
+ const web = generateMoveEphemeralKeyPair();
76
+ const commons = generateMoveEphemeralKeyPair();
77
+ const key = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
78
+ const sealed = sealIdentityForMove(identity, key, MOVE_ID);
79
+ expect(() => openMovedIdentity(sealed, key, 'ffffffffffffffffffffffffffffffff', identity.publicKey)).toThrow();
80
+ });
81
+ });
82
+
83
+ describe('receipts', () => {
84
+ it('verify only when signed by the moved identity for this move', async () => {
85
+ const identity = generateWebIdentity();
86
+ const receipt = await signMoveAction(identity, IDENTITY_MOVE_ACTIONS.received, MOVE_ID);
87
+
88
+ expect(await verifyMoveReceipt(identity.publicKey, MOVE_ID, receipt)).toBe(true);
89
+ expect(await verifyMoveReceipt(identity.publicKey, 'ffffffffffffffffffffffffffffffff', receipt)).toBe(false);
90
+ expect(await verifyMoveReceipt(generateWebIdentity().publicKey, MOVE_ID, receipt)).toBe(false);
91
+
92
+ const sealProof = await signMoveAction(identity, IDENTITY_MOVE_ACTIONS.seal, MOVE_ID);
93
+ expect(await verifyMoveReceipt(identity.publicKey, MOVE_ID, sealProof)).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe('QR payload', () => {
98
+ it('round-trips and rejects anything else', () => {
99
+ expect(parseMoveQrPayload(buildMoveQrPayload(MOVE_ID))).toBe(MOVE_ID);
100
+ expect(parseMoveQrPayload(` ${buildMoveQrPayload(MOVE_ID).toUpperCase()} `)).toBe(MOVE_ID);
101
+ expect(parseMoveQrPayload('oxycommons://approve?code=abc')).toBeNull();
102
+ expect(parseMoveQrPayload('oxycommons://move?id=123')).toBeNull();
103
+ });
104
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The two identity slots must never disagree, because the disagreement is
3
+ * silent and it is money.
4
+ *
5
+ * Cross-app readers take the SHARED slot first — `deriveScopedSeed`, which is
6
+ * where Peable's FairCoin wallet comes from — while signing and the server take
7
+ * the PRIMARY. A key rotation used to write only the primary, so the shared slot
8
+ * kept the replaced key: the recipient's wallet watched addresses derived from a
9
+ * key the DID no longer published, and payers sent to addresses it never
10
+ * watched. Nothing errored on either side.
11
+ */
12
+
13
+ import { setPlatformOS } from '../../utils/platform';
14
+
15
+ jest.mock(
16
+ 'expo-secure-store',
17
+ () => {
18
+ const store = new Map<string, string>();
19
+ return {
20
+ __esModule: true,
21
+ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
22
+ WHEN_UNLOCKED: 'WHEN_UNLOCKED',
23
+ setItemAsync: jest.fn(async (k: string, v: string) => { store.set(k, v); }),
24
+ getItemAsync: jest.fn(async (k: string) => store.get(k) ?? null),
25
+ deleteItemAsync: jest.fn(async (k: string) => { store.delete(k); }),
26
+ __resetStore__: () => store.clear(),
27
+ };
28
+ },
29
+ { virtual: true },
30
+ );
31
+
32
+ jest.mock(
33
+ 'expo-crypto',
34
+ () => ({
35
+ __esModule: true,
36
+ getRandomBytes: (length: number) => {
37
+ const out = new Uint8Array(length);
38
+ for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
39
+ return out;
40
+ },
41
+ digestStringAsync: async () => '0'.repeat(64),
42
+ CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
43
+ }),
44
+ { virtual: true },
45
+ );
46
+
47
+ jest.mock('@oxy.so/protocol', () => ({
48
+ __esModule: true,
49
+ ...jest.requireActual('@oxy.so/protocol'),
50
+ loadExpoCrypto: async () => require('expo-crypto'),
51
+ loadSecureStore: async () => require('expo-secure-store'),
52
+ loadNodeCrypto: async () => require('node:crypto'),
53
+ loadSharedIdentityBridge: async () => null,
54
+ }));
55
+
56
+ const KEY_A = 'aa'.repeat(32);
57
+ const KEY_B = 'bb'.repeat(32);
58
+ const toHex = (b: Uint8Array): string => Buffer.from(b).toString('hex');
59
+
60
+ describe('identity slots stay in sync', () => {
61
+ let KeyManager: typeof import('../keyManager').KeyManager;
62
+
63
+ beforeEach(async () => {
64
+ jest.resetModules();
65
+ setPlatformOS('ios');
66
+ const secureStore = (await import('expo-secure-store' as string)) as unknown as {
67
+ __resetStore__: () => void;
68
+ };
69
+ secureStore.__resetStore__();
70
+ ({ KeyManager } = await import('../keyManager'));
71
+ });
72
+
73
+ /** The shape a rotation used to leave behind: each slot on a different key. */
74
+ async function makeSlotsDisagree(): Promise<{ publicA: string; publicB: string }> {
75
+ const publicA = await KeyManager.importSharedIdentity(KEY_A);
76
+ await KeyManager.importKeyPair(KEY_B, { overwrite: true });
77
+ const publicB = KeyManager.derivePublicKey(KEY_B);
78
+ return { publicA, publicB };
79
+ }
80
+
81
+ it('reports both slots, and which one money derives from', async () => {
82
+ const { publicA, publicB } = await makeSlotsDisagree();
83
+
84
+ const state = await KeyManager.getIdentityKeyState();
85
+
86
+ expect(state.sharedPublicKey).toBe(KeyManager.canonicalPublicKey(publicA));
87
+ expect(state.primaryPublicKey).toBe(KeyManager.canonicalPublicKey(publicB));
88
+ // The shared slot wins, which is exactly why a disagreement is dangerous.
89
+ expect(state.activePublicKey).toBe(state.sharedPublicKey);
90
+ expect(state.inSync).toBe(false);
91
+ });
92
+
93
+ it('is in sync when both slots hold the same key', async () => {
94
+ await KeyManager.importSharedIdentity(KEY_A);
95
+ await KeyManager.importKeyPair(KEY_A, { overwrite: true });
96
+
97
+ const state = await KeyManager.getIdentityKeyState();
98
+
99
+ expect(state.inSync).toBe(true);
100
+ expect(state.activePublicKey).toBe(state.primaryPublicKey);
101
+ });
102
+
103
+ // A device with one slot populated has nothing to contradict, so it must not
104
+ // read as a disagreement — otherwise every fresh install would report one.
105
+ it('is in sync when only one slot is populated', async () => {
106
+ await KeyManager.importKeyPair(KEY_A, { overwrite: true });
107
+
108
+ const state = await KeyManager.getIdentityKeyState();
109
+
110
+ expect(state.sharedPublicKey).toBeNull();
111
+ expect(state.activePublicKey).toBe(state.primaryPublicKey);
112
+ expect(state.inSync).toBe(true);
113
+ });
114
+
115
+ it('repairs a disagreeing shared slot from the primary instead of skipping', async () => {
116
+ const { publicB } = await makeSlotsDisagree();
117
+
118
+ const migrated = await KeyManager.syncSharedIdentity();
119
+
120
+ expect(migrated).toBe(true);
121
+ const state = await KeyManager.getIdentityKeyState();
122
+ expect(state.inSync).toBe(true);
123
+ expect(state.sharedPublicKey).toBe(KeyManager.canonicalPublicKey(publicB));
124
+ });
125
+
126
+ // The wallet is the reason any of this matters: the seed must follow the
127
+ // repaired key, not the one the stale slot was still handing out.
128
+ it('moves the derived wallet seed onto the repaired key', async () => {
129
+ await makeSlotsDisagree();
130
+ const strandedSeed = await KeyManager.deriveScopedSeed('peable/faircoin/v1');
131
+
132
+ await KeyManager.syncSharedIdentity();
133
+ const repairedSeed = await KeyManager.deriveScopedSeed('peable/faircoin/v1');
134
+
135
+ if (!strandedSeed || !repairedSeed) throw new Error('expected seeds');
136
+ expect(toHex(repairedSeed)).not.toBe(toHex(strandedSeed));
137
+
138
+ // And it is the seed a device holding only that one key would derive.
139
+ jest.resetModules();
140
+ const secureStore = (await import('expo-secure-store' as string)) as unknown as {
141
+ __resetStore__: () => void;
142
+ };
143
+ secureStore.__resetStore__();
144
+ const { KeyManager: Fresh } = await import('../keyManager');
145
+ await Fresh.importSharedIdentity(KEY_B);
146
+ const expected = await Fresh.deriveScopedSeed('peable/faircoin/v1');
147
+ if (!expected) throw new Error('expected a seed');
148
+ expect(toHex(repairedSeed)).toBe(toHex(expected));
149
+ });
150
+ });
@@ -0,0 +1,211 @@
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 { verifySignature } from '@oxy.so/protocol';
9
+ import { RecoveryPhraseService } from '../recoveryPhrase';
10
+ import {
11
+ addWrap,
12
+ buildIdentityActionMessage,
13
+ deriveIdentityFromMnemonic,
14
+ deriveTransferSas,
15
+ generateWebIdentity,
16
+ openWebIdentity,
17
+ removeWrap,
18
+ sealWebIdentity,
19
+ signIdentityAction,
20
+ unlockWebIdentity,
21
+ unwrapDataKey,
22
+ WEB_IDENTITY_PRF_INPUT,
23
+ WebIdentityUnlockError,
24
+ wipeBytes,
25
+ } from '../webIdentityCarrier';
26
+
27
+ /** The BIP-39 reference mnemonic (all-zero entropy). */
28
+ const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
29
+
30
+ const prf = (fill: number): Uint8Array => new Uint8Array(32).fill(fill);
31
+ const CREDENTIAL_A = 'credential-aaaaaaaaaaaaaaaa';
32
+ const CREDENTIAL_B = 'credential-bbbbbbbbbbbbbbbb';
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('the identity itself', () => {
44
+ it('derives the SAME key Commons derives from a phrase — one identity, not a second kind', async () => {
45
+ const identity = deriveIdentityFromMnemonic(MNEMONIC);
46
+
47
+ expect(identity.privateKey).toBe(await RecoveryPhraseService.derivePrivateKeyFromPhrase(MNEMONIC));
48
+ expect(identity.publicKey).toBe(await RecoveryPhraseService.derivePublicKeyFromPhrase(MNEMONIC));
49
+ });
50
+
51
+ it('normalizes case and spacing the way a person types a phrase', () => {
52
+ const typed = ` ${MNEMONIC.toUpperCase().split(' ').join(' ')} `;
53
+ expect(deriveIdentityFromMnemonic(typed).publicKey).toBe(deriveIdentityFromMnemonic(MNEMONIC).publicKey);
54
+ });
55
+
56
+ it('rejects an invalid phrase', () => {
57
+ expect(() => deriveIdentityFromMnemonic('abandon abandon abandon')).toThrow('Invalid recovery phrase');
58
+ });
59
+
60
+ it('generates a fresh 12-word identity in canonical public-key form', () => {
61
+ const identity = generateWebIdentity();
62
+ expect(identity.mnemonic.split(' ')).toHaveLength(12);
63
+ expect(identity.publicKey).toMatch(/^04[0-9a-f]{128}$/);
64
+ expect(generateWebIdentity().publicKey).not.toBe(identity.publicKey);
65
+ });
66
+
67
+ it('uses a fixed 32-byte PRF input', () => {
68
+ expect(WEB_IDENTITY_PRF_INPUT).toHaveLength(32);
69
+ });
70
+ });
71
+
72
+ describe('sealing and unlocking', () => {
73
+ it('round-trips: the passkey that sealed it opens it into the same identity', () => {
74
+ const identity = deriveIdentityFromMnemonic(MNEMONIC);
75
+ const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(7), credentialId: CREDENTIAL_A });
76
+ wipeBytes(dataKey);
77
+
78
+ // The stored shape is exactly the published contract.
79
+ expect(webIdentityEnvelopeSchema.parse(envelope)).toEqual(envelope);
80
+ // Nothing identifying the secret is visible in the envelope.
81
+ expect(JSON.stringify(envelope)).not.toContain(identity.privateKey);
82
+
83
+ const opened = unlockWebIdentity(envelope, prf(7), CREDENTIAL_A);
84
+ expect(opened).toEqual(identity);
85
+ });
86
+
87
+ it('refuses to seal a phrase under a public key it does not belong to', () => {
88
+ const other = generateWebIdentity();
89
+ expect(() =>
90
+ sealWebIdentity({ mnemonic: MNEMONIC, publicKey: other.publicKey }, { prfOutput: prf(1), credentialId: CREDENTIAL_A }),
91
+ ).toThrow('does not belong');
92
+ });
93
+
94
+ it('reports an unregistered passkey as unknown-credential', () => {
95
+ const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A });
96
+ expect(failureOf(() => unlockWebIdentity(envelope, prf(7), CREDENTIAL_B))).toBe('unknown-credential');
97
+ });
98
+
99
+ it('reports a registered passkey returning a different PRF value as prf-mismatch (recoverable)', () => {
100
+ const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A });
101
+ expect(failureOf(() => unlockWebIdentity(envelope, prf(8), CREDENTIAL_A))).toBe('prf-mismatch');
102
+ });
103
+
104
+ it('refuses a wrap transplanted onto another credential id', () => {
105
+ const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A });
106
+ const transplanted = { ...envelope, wraps: [{ ...envelope.wraps[0], credentialId: CREDENTIAL_B }] };
107
+ // The associated data binds each wrap to its credential.
108
+ expect(failureOf(() => unlockWebIdentity(transplanted, prf(7), CREDENTIAL_B))).toBe('prf-mismatch');
109
+ });
110
+
111
+ it('refuses an envelope re-labelled with another identity public key', () => {
112
+ const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), {
113
+ prfOutput: prf(7),
114
+ credentialId: CREDENTIAL_A,
115
+ });
116
+ const relabelled = { ...envelope, publicKey: generateWebIdentity().publicKey };
117
+ expect(failureOf(() => openWebIdentity(relabelled, dataKey))).toBe('corrupt');
118
+ });
119
+
120
+ it('treats tampered sealed entropy as corrupt, never as a different identity', () => {
121
+ const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), {
122
+ prfOutput: prf(7),
123
+ credentialId: CREDENTIAL_A,
124
+ });
125
+ const flipped = envelope.sealedEntropy.startsWith('0') ? `1${envelope.sealedEntropy.slice(1)}` : `0${envelope.sealedEntropy.slice(1)}`;
126
+ expect(failureOf(() => openWebIdentity({ ...envelope, sealedEntropy: flipped }, dataKey))).toBe('corrupt');
127
+ });
128
+ });
129
+
130
+ describe('more than one passkey', () => {
131
+ it('opens with every registered passkey and keeps a single sealed identity', () => {
132
+ const identity = deriveIdentityFromMnemonic(MNEMONIC);
133
+ const sealed = sealWebIdentity(identity, { prfOutput: prf(1), credentialId: CREDENTIAL_A });
134
+ const withTwo = addWrap(sealed.envelope, sealed.dataKey, prf(2), CREDENTIAL_B);
135
+
136
+ expect(withTwo.wraps.map((wrap) => wrap.credentialId)).toEqual([CREDENTIAL_A, CREDENTIAL_B]);
137
+ expect(withTwo.sealedEntropy).toBe(sealed.envelope.sealedEntropy);
138
+ expect(unlockWebIdentity(withTwo, prf(1), CREDENTIAL_A).publicKey).toBe(identity.publicKey);
139
+ expect(unlockWebIdentity(withTwo, prf(2), CREDENTIAL_B).publicKey).toBe(identity.publicKey);
140
+ });
141
+
142
+ it('replaces a passkey’s wrap instead of duplicating it', () => {
143
+ const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A });
144
+ const rewrapped = addWrap(sealed.envelope, sealed.dataKey, prf(9), CREDENTIAL_A);
145
+ expect(rewrapped.wraps).toHaveLength(1);
146
+ expect(failureOf(() => unlockWebIdentity(rewrapped, prf(1), CREDENTIAL_A))).toBe('prf-mismatch');
147
+ expect(unlockWebIdentity(rewrapped, prf(9), CREDENTIAL_A).mnemonic).toBe(MNEMONIC);
148
+ });
149
+
150
+ it('will not add a wrap with a data key from a different envelope', () => {
151
+ const first = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A });
152
+ const second = sealWebIdentity(generateWebIdentity(), { prfOutput: prf(1), credentialId: CREDENTIAL_A });
153
+ expect(() => addWrap(first.envelope, second.dataKey, prf(2), CREDENTIAL_B)).toThrow();
154
+ });
155
+
156
+ it('removes a passkey, but never the last one', () => {
157
+ const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A });
158
+ const withTwo = addWrap(sealed.envelope, sealed.dataKey, prf(2), CREDENTIAL_B);
159
+ const withOne = removeWrap(withTwo, CREDENTIAL_A);
160
+
161
+ expect(withOne.wraps.map((wrap) => wrap.credentialId)).toEqual([CREDENTIAL_B]);
162
+ expect(() => removeWrap(withOne, CREDENTIAL_B)).toThrow('at least one passkey');
163
+ });
164
+
165
+ it('keeps the data key usable only until it is wiped', () => {
166
+ const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A });
167
+ const recovered = unwrapDataKey(sealed.envelope, prf(1), CREDENTIAL_A);
168
+ wipeBytes(recovered);
169
+ expect(failureOf(() => openWebIdentity(sealed.envelope, recovered))).toBe('corrupt');
170
+ });
171
+ });
172
+
173
+ describe('identity actions', () => {
174
+ it('signs the exact message the API reconstructs, verifiable with the public key', async () => {
175
+ const identity = deriveIdentityFromMnemonic(MNEMONIC);
176
+ const { signature, timestamp } = await signIdentityAction(identity, 'link_identity', 'user-1', 1_700_000_000_000);
177
+
178
+ const message = buildIdentityActionMessage('link_identity', 'user-1', timestamp);
179
+ expect(message).toBe('{"action":"link_identity","userId":"user-1","timestamp":1700000000000}');
180
+ expect(await verifySignature(message, signature, identity.publicKey)).toBe(true);
181
+ expect(await verifySignature(buildIdentityActionMessage('web_envelope_delete', 'user-1', timestamp), signature, identity.publicKey)).toBe(false);
182
+ });
183
+ });
184
+
185
+ describe('transfer short authentication string', () => {
186
+ const base = {
187
+ pairingId: 'ab'.repeat(16),
188
+ initiatorEphemeralPublicKey: generateWebIdentity().publicKey,
189
+ responderEphemeralPublicKey: generateWebIdentity().publicKey,
190
+ };
191
+
192
+ it('is six digits and identical on both sides', () => {
193
+ const sas = deriveTransferSas(base);
194
+ expect(sas).toMatch(/^\d{6}$/);
195
+ expect(deriveTransferSas({ ...base, pairingId: base.pairingId.toUpperCase() })).toBe(sas);
196
+ });
197
+
198
+ it('changes when the relay substitutes either ephemeral key, or swaps the roles', () => {
199
+ const sas = deriveTransferSas(base);
200
+ const attacker = generateWebIdentity().publicKey;
201
+ expect(deriveTransferSas({ ...base, responderEphemeralPublicKey: attacker })).not.toBe(sas);
202
+ expect(deriveTransferSas({ ...base, initiatorEphemeralPublicKey: attacker })).not.toBe(sas);
203
+ expect(
204
+ deriveTransferSas({
205
+ ...base,
206
+ initiatorEphemeralPublicKey: base.responderEphemeralPublicKey,
207
+ responderEphemeralPublicKey: base.initiatorEphemeralPublicKey,
208
+ }),
209
+ ).not.toBe(sas);
210
+ });
211
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Identity move — the crypto of taking a web identity into Commons.
3
+ *
4
+ * Both carriers run this: `id.oxy.so` (the initiator, holding the identity) and
5
+ * Commons (the responder, receiving it). The relay in between sees two ephemeral
6
+ * public keys and ciphertext; the 6-digit SAS both screens show is what makes a
7
+ * relay that swapped a key visible to the person.
8
+ *
9
+ * PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
10
+ */
11
+
12
+ import './polyfill';
13
+ import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
14
+ import { wordlist } from '@scure/bip39/wordlists/english';
15
+ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
16
+ import { signMessage, verifySignature } from '@oxy.so/protocol';
17
+ import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
18
+ import { IDENTITY_MOVE_QR_PREFIX } from '@oxy.so/contracts';
19
+ import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead';
20
+ import { deriveSharedSecret } from './ecdh';
21
+ import { hkdfSha256 } from './kdf';
22
+ import { deriveIdentityFromMnemonic, deriveTransferSas, wipeBytes, type OpenedWebIdentity } from './webIdentityCarrier';
23
+
24
+ const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
25
+
26
+ /** Actions the identity key signs during a move. */
27
+ export const IDENTITY_MOVE_ACTIONS = {
28
+ seal: 'identity_move_seal',
29
+ received: 'identity_move_received',
30
+ } as const;
31
+
32
+ /** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
33
+ export function generateMoveEphemeralKeyPair(): { privateKey: string; publicKey: string } {
34
+ return generateSecp256k1KeyPair();
35
+ }
36
+
37
+ /** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
38
+ export function deriveMoveKey(ownEphemeralPrivateKey: string, otherEphemeralPublicKey: string, moveId: string): Uint8Array {
39
+ const shared = deriveSharedSecret(ownEphemeralPrivateKey, otherEphemeralPublicKey);
40
+ try {
41
+ return hkdfSha256(shared, utf8ToBytes(moveId.toLowerCase()), MOVE_KDF_INFO, AEAD_KEY_LENGTH);
42
+ } finally {
43
+ wipeBytes(shared);
44
+ }
45
+ }
46
+
47
+ /** The 6-digit code both screens show, bound to the move and both ephemeral keys in their roles. */
48
+ export function deriveMoveSas(moveId: string, initiatorEphemeralPublicKey: string, responderEphemeralPublicKey: string): string {
49
+ return deriveTransferSas({ pairingId: moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey });
50
+ }
51
+
52
+ function moveAad(moveId: string, publicKey: string): Uint8Array {
53
+ return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
54
+ }
55
+
56
+ /** Initiator: seal the identity's entropy for the responder. */
57
+ export function sealIdentityForMove(
58
+ identity: Pick<OpenedWebIdentity, 'mnemonic' | 'publicKey'>,
59
+ moveKey: Uint8Array,
60
+ moveId: string,
61
+ ): { nonce: string; ciphertext: string } {
62
+ const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
63
+ try {
64
+ const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
65
+ return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(ciphertext) };
66
+ } finally {
67
+ wipeBytes(entropy);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Responder: open the sealed identity. The derived public key must be the one
73
+ * the move declared — anything else is refused, never imported.
74
+ */
75
+ export function openMovedIdentity(
76
+ sealed: { nonce: string; ciphertext: string },
77
+ moveKey: Uint8Array,
78
+ moveId: string,
79
+ expectedPublicKey: string,
80
+ ): OpenedWebIdentity {
81
+ let entropy: Uint8Array;
82
+ try {
83
+ entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
84
+ } catch {
85
+ throw new Error('The identity could not be opened. The codes may not have matched — start again.');
86
+ }
87
+ try {
88
+ const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
89
+ if (identity.publicKey !== expectedPublicKey.toLowerCase()) {
90
+ throw new Error('The received identity is not the one being moved');
91
+ }
92
+ return identity;
93
+ } finally {
94
+ wipeBytes(entropy);
95
+ }
96
+ }
97
+
98
+ /** The exact bytes a move proof or receipt signs. */
99
+ export function buildMoveMessage(action: string, moveId: string, timestamp: number): string {
100
+ return JSON.stringify({ action, moveId: moveId.toLowerCase(), timestamp });
101
+ }
102
+
103
+ /** Sign a move action with the identity key. */
104
+ export async function signMoveAction(
105
+ identity: Pick<OpenedWebIdentity, 'privateKey'>,
106
+ action: string,
107
+ moveId: string,
108
+ timestamp: number = Date.now(),
109
+ ): Promise<{ signature: string; timestamp: number }> {
110
+ return { signature: await signMessage(buildMoveMessage(action, moveId, timestamp), identity.privateKey), timestamp };
111
+ }
112
+
113
+ /**
114
+ * Initiator: is this receipt really from the identity? Checked locally with the
115
+ * identity's public key, so a server that claims "completed" without Commons
116
+ * having the key cannot make the web destroy its copy.
117
+ */
118
+ export function verifyMoveReceipt(publicKey: string, moveId: string, receipt: { signature: string; timestamp: number }): Promise<boolean> {
119
+ return verifySignature(buildMoveMessage(IDENTITY_MOVE_ACTIONS.received, moveId, receipt.timestamp), receipt.signature, publicKey);
120
+ }
121
+
122
+ /** The QR payload for a move. */
123
+ export function buildMoveQrPayload(moveId: string): string {
124
+ return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
125
+ }
126
+
127
+ /** The move id in a scanned payload, or `null` when it is not a move QR. */
128
+ export function parseMoveQrPayload(raw: string): string | null {
129
+ const value = raw.trim();
130
+ if (!value.toLowerCase().startsWith(IDENTITY_MOVE_QR_PREFIX)) return null;
131
+ const id = value.slice(IDENTITY_MOVE_QR_PREFIX.length).toLowerCase();
132
+ return /^[0-9a-f]{32}$/.test(id) ? id : null;
133
+ }