@oxy.so/core 1.5.1 → 1.6.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 (126) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +72 -9
  3. package/dist/cjs/crypto/identityMove.js +53 -34
  4. package/dist/cjs/crypto/identityProof.js +28 -0
  5. package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
  6. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  7. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  9. package/dist/cjs/i18n/locales/en-US.json +1 -0
  10. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  11. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  12. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  13. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  14. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  23. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  24. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  25. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  26. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  27. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  28. package/dist/cjs/index.js +22 -14
  29. package/dist/cjs/inference/OxyInferenceClient.js +62 -5
  30. package/dist/cjs/mixins/OxyServices.auth.js +129 -0
  31. package/dist/cjs/mixins/OxyServices.identity.js +11 -44
  32. package/dist/cjs/mixins/index.js +0 -2
  33. package/dist/cjs/server/index.js +10 -1
  34. package/dist/cjs/server/securityHeaders.js +9 -2
  35. package/dist/cjs/server/workloadIdentity.js +170 -0
  36. package/dist/cjs/session/refresh.js +12 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/HttpService.js +72 -9
  39. package/dist/esm/crypto/identityMove.js +51 -33
  40. package/dist/esm/crypto/identityProof.js +24 -0
  41. package/dist/esm/crypto/webIdentityCarrier.js +137 -77
  42. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  44. package/dist/esm/i18n/locales/de-DE.json +1 -0
  45. package/dist/esm/i18n/locales/en-US.json +1 -0
  46. package/dist/esm/i18n/locales/es-ES.json +1 -0
  47. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  48. package/dist/esm/i18n/locales/it-IT.json +1 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  54. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  62. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  64. package/dist/esm/index.js +5 -4
  65. package/dist/esm/inference/OxyInferenceClient.js +63 -6
  66. package/dist/esm/mixins/OxyServices.auth.js +96 -0
  67. package/dist/esm/mixins/OxyServices.identity.js +11 -44
  68. package/dist/esm/mixins/index.js +0 -2
  69. package/dist/esm/server/index.js +7 -0
  70. package/dist/esm/server/securityHeaders.js +9 -2
  71. package/dist/esm/server/workloadIdentity.js +166 -0
  72. package/dist/esm/session/refresh.js +12 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/HttpService.d.ts +24 -0
  75. package/dist/types/crypto/identityMove.d.ts +48 -32
  76. package/dist/types/crypto/identityProof.d.ts +16 -0
  77. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  78. package/dist/types/index.d.ts +7 -6
  79. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  80. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  81. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  82. package/dist/types/mixins/index.d.ts +1 -2
  83. package/dist/types/server/index.d.ts +8 -0
  84. package/dist/types/server/securityHeaders.d.ts +8 -0
  85. package/dist/types/server/workloadIdentity.d.ts +32 -0
  86. package/package.json +1 -1
  87. package/src/HttpService.ts +74 -9
  88. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  89. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  90. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  91. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  92. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  93. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  94. package/src/crypto/identityMove.ts +77 -43
  95. package/src/crypto/identityProof.ts +36 -0
  96. package/src/crypto/webIdentityCarrier.ts +185 -117
  97. package/src/i18n/locales/ar-SA.json +1 -0
  98. package/src/i18n/locales/ca-ES.json +1 -0
  99. package/src/i18n/locales/de-DE.json +1 -0
  100. package/src/i18n/locales/en-US.json +1 -0
  101. package/src/i18n/locales/es-ES.json +1 -0
  102. package/src/i18n/locales/fr-FR.json +1 -0
  103. package/src/i18n/locales/it-IT.json +1 -0
  104. package/src/i18n/locales/ja-JP.json +1 -0
  105. package/src/i18n/locales/ko-KR.json +1 -0
  106. package/src/i18n/locales/pt-PT.json +1 -0
  107. package/src/i18n/locales/zh-CN.json +1 -0
  108. package/src/index.ts +26 -15
  109. package/src/inference/OxyInferenceClient.ts +84 -7
  110. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  111. package/src/mixins/OxyServices.auth.ts +100 -1
  112. package/src/mixins/OxyServices.identity.ts +17 -67
  113. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  114. package/src/mixins/index.ts +0 -3
  115. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  116. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  117. package/src/server/index.ts +9 -0
  118. package/src/server/securityHeaders.ts +19 -6
  119. package/src/server/workloadIdentity.ts +207 -0
  120. package/src/session/__tests__/refresh.test.ts +53 -3
  121. package/src/session/refresh.ts +12 -0
  122. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  123. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  124. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  125. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  126. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The one identity-proof format (ADR 0024 D7): the signed bytes name every
3
+ * claim, so a signature for one operation verifies for nothing else.
4
+ */
5
+
6
+ import { buildIdentityProofMessage, canonicalJson, type IdentityProofClaims } from '@oxy.so/contracts';
7
+ import { verifySignature } from '@oxy.so/protocol';
8
+ import { digestIdentityPayload, signIdentityProof } from '../identityProof';
9
+ import { deriveIdentityFromMnemonic } from '../webIdentityCarrier';
10
+
11
+ const identity = deriveIdentityFromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
12
+
13
+ const claims = (overrides: Partial<IdentityProofClaims> = {}): IdentityProofClaims => ({
14
+ action: 'web_envelope_put',
15
+ subject: 'user-1',
16
+ actor: 'user-1',
17
+ rootPublicKey: identity.publicKey,
18
+ payloadDigest: digestIdentityPayload({ b: 1, a: [1, 'x'] }),
19
+ expectedRevision: 3,
20
+ audience: 'oxy-api/identity',
21
+ challenge: 'ab'.repeat(32),
22
+ expiresAt: 1_800_000_000_000,
23
+ ...overrides,
24
+ });
25
+
26
+ describe('canonical JSON', () => {
27
+ it('sorts keys at every depth and drops undefined members', () => {
28
+ expect(canonicalJson({ b: 1, a: { d: [3, { z: 1, y: 2 }], c: undefined } })).toBe('{"a":{"d":[3,{"y":2,"z":1}]},"b":1}');
29
+ });
30
+
31
+ it('digests the same payload identically whatever its key order', () => {
32
+ expect(digestIdentityPayload({ a: 1, b: 2 })).toBe(digestIdentityPayload({ b: 2, a: 1 }));
33
+ expect(digestIdentityPayload({ a: 1, b: 2 })).toMatch(/^[0-9a-f]{64}$/);
34
+ });
35
+
36
+ it('refuses values JSON cannot represent exactly', () => {
37
+ expect(() => canonicalJson({ a: Number.NaN })).toThrow('non-finite');
38
+ expect(() => canonicalJson({ a: () => 1 })).toThrow('unsupported');
39
+ });
40
+ });
41
+
42
+ describe('the signed bytes', () => {
43
+ it('are a fixed, versioned, domain-separated shape', () => {
44
+ expect(buildIdentityProofMessage(claims())).toBe(
45
+ `{"action":"web_envelope_put","actor":"user-1","audience":"oxy-api/identity","challenge":"${'ab'.repeat(32)}","domain":"oxy-identity-proof","expectedRevision":3,"expiresAt":1800000000000,"payloadDigest":"${claims().payloadDigest}","rootPublicKey":"${identity.publicKey}","subject":"user-1","v":2}`,
46
+ );
47
+ });
48
+
49
+ it('refuse malformed claims instead of signing something ambiguous', () => {
50
+ expect(() => buildIdentityProofMessage(claims({ action: 'anything' as never }))).toThrow('unknown action');
51
+ expect(() => buildIdentityProofMessage(claims({ payloadDigest: 'AB'.repeat(32) }))).toThrow('payloadDigest');
52
+ expect(() => buildIdentityProofMessage(claims({ challenge: 'short' }))).toThrow('challenge');
53
+ expect(() => buildIdentityProofMessage(claims({ rootPublicKey: identity.publicKey.toUpperCase() }))).toThrow('rootPublicKey');
54
+ });
55
+ });
56
+
57
+ describe('signing', () => {
58
+ it('verifies for exactly the claims it was made for', async () => {
59
+ const proof = await signIdentityProof(identity, claims());
60
+ expect(proof).toMatchObject({ v: 2, challenge: 'ab'.repeat(32), expiresAt: 1_800_000_000_000 });
61
+
62
+ expect(await verifySignature(buildIdentityProofMessage(claims()), proof.signature, identity.publicKey)).toBe(true);
63
+ for (const changed of [
64
+ claims({ action: 'web_envelope_delete' }),
65
+ claims({ subject: 'user-2' }),
66
+ claims({ actor: 'user-2' }),
67
+ claims({ payloadDigest: digestIdentityPayload({ b: 2 }) }),
68
+ claims({ expectedRevision: 4 }),
69
+ claims({ audience: 'somewhere-else' }),
70
+ claims({ challenge: 'cd'.repeat(32) }),
71
+ claims({ expiresAt: 1_800_000_000_001 }),
72
+ ]) {
73
+ expect(await verifySignature(buildIdentityProofMessage(changed), proof.signature, identity.publicKey)).toBe(false);
74
+ }
75
+ });
76
+
77
+ it('will not sign claims naming a different root', async () => {
78
+ const other = deriveIdentityFromMnemonic('legal winner thank year wave sausage worth useful legal winner thank yellow');
79
+ await expect(signIdentityProof(identity, claims({ rootPublicKey: other.publicKey }))).rejects.toThrow('different root');
80
+ });
81
+ });
@@ -5,18 +5,14 @@
5
5
  */
6
6
 
7
7
  import { webIdentityEnvelopeSchema } from '@oxy.so/contracts';
8
- import { verifySignature } from '@oxy.so/protocol';
9
8
  import { RecoveryPhraseService } from '../recoveryPhrase';
10
9
  import {
11
10
  addWrap,
12
- buildIdentityActionMessage,
13
11
  deriveIdentityFromMnemonic,
14
- deriveTransferSas,
15
12
  generateWebIdentity,
16
13
  openWebIdentity,
17
14
  removeWrap,
18
15
  sealWebIdentity,
19
- signIdentityAction,
20
16
  unlockWebIdentity,
21
17
  unwrapDataKey,
22
18
  WEB_IDENTITY_PRF_INPUT,
@@ -72,7 +68,7 @@ describe('the identity itself', () => {
72
68
  describe('sealing and unlocking', () => {
73
69
  it('round-trips: the passkey that sealed it opens it into the same identity', () => {
74
70
  const identity = deriveIdentityFromMnemonic(MNEMONIC);
75
- const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(7), credentialId: CREDENTIAL_A });
71
+ const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
76
72
  wipeBytes(dataKey);
77
73
 
78
74
  // The stored shape is exactly the published contract.
@@ -87,22 +83,22 @@ describe('sealing and unlocking', () => {
87
83
  it('refuses to seal a phrase under a public key it does not belong to', () => {
88
84
  const other = generateWebIdentity();
89
85
  expect(() =>
90
- sealWebIdentity({ mnemonic: MNEMONIC, publicKey: other.publicKey }, { prfOutput: prf(1), credentialId: CREDENTIAL_A }),
86
+ sealWebIdentity({ ...deriveIdentityFromMnemonic(MNEMONIC), publicKey: other.publicKey }, { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' }),
91
87
  ).toThrow('does not belong');
92
88
  });
93
89
 
94
90
  it('reports an unregistered passkey as unknown-credential', () => {
95
- const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A });
91
+ const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
96
92
  expect(failureOf(() => unlockWebIdentity(envelope, prf(7), CREDENTIAL_B))).toBe('unknown-credential');
97
93
  });
98
94
 
99
95
  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 });
96
+ const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
101
97
  expect(failureOf(() => unlockWebIdentity(envelope, prf(8), CREDENTIAL_A))).toBe('prf-mismatch');
102
98
  });
103
99
 
104
100
  it('refuses a wrap transplanted onto another credential id', () => {
105
- const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A });
101
+ const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
106
102
  const transplanted = { ...envelope, wraps: [{ ...envelope.wraps[0], credentialId: CREDENTIAL_B }] };
107
103
  // The associated data binds each wrap to its credential.
108
104
  expect(failureOf(() => unlockWebIdentity(transplanted, prf(7), CREDENTIAL_B))).toBe('prf-mismatch');
@@ -112,50 +108,52 @@ describe('sealing and unlocking', () => {
112
108
  const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), {
113
109
  prfOutput: prf(7),
114
110
  credentialId: CREDENTIAL_A,
111
+ rpId: 'oxy.so',
115
112
  });
116
113
  const relabelled = { ...envelope, publicKey: generateWebIdentity().publicKey };
117
114
  expect(failureOf(() => openWebIdentity(relabelled, dataKey))).toBe('corrupt');
118
115
  });
119
116
 
120
- it('treats tampered sealed entropy as corrupt, never as a different identity', () => {
117
+ it('treats a tampered sealed secret as corrupt, never as a different identity', () => {
121
118
  const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), {
122
119
  prfOutput: prf(7),
123
120
  credentialId: CREDENTIAL_A,
121
+ rpId: 'oxy.so',
124
122
  });
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');
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');
127
125
  });
128
126
  });
129
127
 
130
128
  describe('more than one passkey', () => {
131
129
  it('opens with every registered passkey and keeps a single sealed identity', () => {
132
130
  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);
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' });
135
133
 
136
134
  expect(withTwo.wraps.map((wrap) => wrap.credentialId)).toEqual([CREDENTIAL_A, CREDENTIAL_B]);
137
- expect(withTwo.sealedEntropy).toBe(sealed.envelope.sealedEntropy);
135
+ expect(withTwo.sealedSecret).toBe(sealed.envelope.sealedSecret);
138
136
  expect(unlockWebIdentity(withTwo, prf(1), CREDENTIAL_A).publicKey).toBe(identity.publicKey);
139
137
  expect(unlockWebIdentity(withTwo, prf(2), CREDENTIAL_B).publicKey).toBe(identity.publicKey);
140
138
  });
141
139
 
142
140
  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);
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' });
145
143
  expect(rewrapped.wraps).toHaveLength(1);
146
144
  expect(failureOf(() => unlockWebIdentity(rewrapped, prf(1), CREDENTIAL_A))).toBe('prf-mismatch');
147
145
  expect(unlockWebIdentity(rewrapped, prf(9), CREDENTIAL_A).mnemonic).toBe(MNEMONIC);
148
146
  });
149
147
 
150
148
  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();
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();
154
152
  });
155
153
 
156
154
  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);
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' });
159
157
  const withOne = removeWrap(withTwo, CREDENTIAL_A);
160
158
 
161
159
  expect(withOne.wraps.map((wrap) => wrap.credentialId)).toEqual([CREDENTIAL_B]);
@@ -163,49 +161,9 @@ describe('more than one passkey', () => {
163
161
  });
164
162
 
165
163
  it('keeps the data key usable only until it is wiped', () => {
166
- const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A });
164
+ const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
167
165
  const recovered = unwrapDataKey(sealed.envelope, prf(1), CREDENTIAL_A);
168
166
  wipeBytes(recovered);
169
167
  expect(failureOf(() => openWebIdentity(sealed.envelope, recovered))).toBe('corrupt');
170
168
  });
171
169
  });
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,117 @@
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,10 +1,13 @@
1
1
  /**
2
- * Identity move — the crypto of taking a web identity into Commons.
2
+ * Identity move — the crypto of giving a web root to Commons (ADR 0024 D6).
3
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.
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.
8
11
  *
9
12
  * PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
10
13
  */
@@ -12,22 +15,27 @@
12
15
  import './polyfill';
13
16
  import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
14
17
  import { wordlist } from '@scure/bip39/wordlists/english';
18
+ import { sha256 } from '@noble/hashes/sha256';
15
19
  import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
16
- import { signMessage, verifySignature } from '@oxy.so/protocol';
20
+ import { verifySignature } from '@oxy.so/protocol';
17
21
  import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
18
- import { IDENTITY_MOVE_QR_PREFIX } from '@oxy.so/contracts';
22
+ import {
23
+ IDENTITY_MOVE_QR_PREFIX,
24
+ buildMoveCiphertextDigestInput,
25
+ buildMoveCommitmentInput,
26
+ buildMoveReceiptMessage,
27
+ buildMoveSasInput,
28
+ } from '@oxy.so/contracts';
19
29
  import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead';
20
30
  import { deriveSharedSecret } from './ecdh';
21
31
  import { hkdfSha256 } from './kdf';
22
- import { deriveIdentityFromMnemonic, deriveTransferSas, wipeBytes, type OpenedWebIdentity } from './webIdentityCarrier';
32
+ import { deriveIdentityFromMnemonic, wipeBytes, type OpenedMnemonicIdentity } from './webIdentityCarrier';
23
33
 
24
34
  const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
25
35
 
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;
36
+ function sha256Hex(input: string): string {
37
+ return bytesToHex(sha256(utf8ToBytes(input)));
38
+ }
31
39
 
32
40
  /** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
33
41
  export function generateMoveEphemeralKeyPair(): { privateKey: string; publicKey: string } {
@@ -44,21 +52,71 @@ export function deriveMoveKey(ownEphemeralPrivateKey: string, otherEphemeralPubl
44
52
  }
45
53
  }
46
54
 
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 });
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);
50
105
  }
51
106
 
52
107
  function moveAad(moveId: string, publicKey: string): Uint8Array {
53
108
  return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
54
109
  }
55
110
 
56
- /** Initiator: seal the identity's entropy for the responder. */
111
+ /** Initiator: seal the phrase entropy (12–24 words) for the responder. */
57
112
  export function sealIdentityForMove(
58
- identity: Pick<OpenedWebIdentity, 'mnemonic' | 'publicKey'>,
113
+ identity: Pick<OpenedMnemonicIdentity, 'mnemonic' | 'publicKey'>,
59
114
  moveKey: Uint8Array,
60
115
  moveId: string,
61
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
+ }
62
120
  const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
63
121
  try {
64
122
  const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
@@ -77,7 +135,7 @@ export function openMovedIdentity(
77
135
  moveKey: Uint8Array,
78
136
  moveId: string,
79
137
  expectedPublicKey: string,
80
- ): OpenedWebIdentity {
138
+ ): OpenedMnemonicIdentity {
81
139
  let entropy: Uint8Array;
82
140
  try {
83
141
  entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
@@ -95,30 +153,6 @@ export function openMovedIdentity(
95
153
  }
96
154
  }
97
155
 
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
156
  /** The QR payload for a move. */
123
157
  export function buildMoveQrPayload(moveId: string): string {
124
158
  return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Signing the one identity-proof format (ADR 0024 D7).
3
+ *
4
+ * The bytes come from `@oxy.so/contracts` `buildIdentityProofMessage`, which the
5
+ * API verifies against; payload digests hash `canonicalJson`. Nothing here builds
6
+ * its own JSON.
7
+ */
8
+
9
+ import './polyfill';
10
+ import { sha256 } from '@noble/hashes/sha256';
11
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
12
+ import { signMessage } from '@oxy.so/protocol';
13
+ import {
14
+ IDENTITY_PROOF_VERSION,
15
+ buildIdentityProofMessage,
16
+ canonicalJson,
17
+ type IdentityProof,
18
+ type IdentityProofClaims,
19
+ } from '@oxy.so/contracts';
20
+
21
+ /** SHA-256 hex of the canonical JSON of `payload` — the `payloadDigest` claim. */
22
+ export function digestIdentityPayload(payload: unknown): string {
23
+ return bytesToHex(sha256(utf8ToBytes(canonicalJson(payload))));
24
+ }
25
+
26
+ /** Sign `claims` with the root, producing the proof as it travels. */
27
+ export async function signIdentityProof(
28
+ identity: { privateKey: string; publicKey: string },
29
+ claims: IdentityProofClaims,
30
+ ): Promise<IdentityProof> {
31
+ if (claims.rootPublicKey !== identity.publicKey.toLowerCase()) {
32
+ throw new Error('identity proof: the claims name a different root');
33
+ }
34
+ const signature = await signMessage(buildIdentityProofMessage(claims), identity.privateKey);
35
+ return { v: IDENTITY_PROOF_VERSION, challenge: claims.challenge, expiresAt: claims.expiresAt, signature };
36
+ }