@learncard/sss-key-manager 0.2.1 → 0.2.2

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 (44) hide show
  1. package/dist/escrow-attestation.d.ts +2 -9
  2. package/dist/escrow-attestation.d.ts.map +1 -1
  3. package/dist/escrow-authority.d.ts +112 -0
  4. package/dist/escrow-authority.d.ts.map +1 -0
  5. package/dist/escrow-authority.types.d.ts +51 -0
  6. package/dist/escrow-authority.types.d.ts.map +1 -0
  7. package/dist/escrow-crypto.d.ts +9 -1
  8. package/dist/escrow-crypto.d.ts.map +1 -1
  9. package/dist/escrow-enrollment-intent.d.ts +13 -0
  10. package/dist/escrow-enrollment-intent.d.ts.map +1 -0
  11. package/dist/escrow-enrollment-intent.types.d.ts +28 -0
  12. package/dist/escrow-enrollment-intent.types.d.ts.map +1 -0
  13. package/dist/escrow-nitro-attestation.d.ts +2 -0
  14. package/dist/escrow-nitro-attestation.d.ts.map +1 -1
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/sss-key-manager.cjs.development.js +1592 -213
  18. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  19. package/dist/sss-key-manager.cjs.production.min.js +28 -23
  20. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  21. package/dist/sss-key-manager.esm.js +1107 -163
  22. package/dist/sss-key-manager.esm.js.map +4 -4
  23. package/dist/sss-strategy.d.ts.map +1 -1
  24. package/dist/types.d.ts +16 -1
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/__fixtures__/escrow-authority-v1.json +96 -0
  28. package/src/__fixtures__/escrow-carry-contact-contract-v1.json +33 -0
  29. package/src/__fixtures__/escrow-enrollment-intent-v1.json +26 -0
  30. package/src/__fixtures__/nitro-attestation-real/staging-nsm-document.json +16 -0
  31. package/src/escrow-attestation.ts +10 -9
  32. package/src/escrow-authority.test.ts +643 -0
  33. package/src/escrow-authority.ts +597 -0
  34. package/src/escrow-authority.types.ts +56 -0
  35. package/src/escrow-crypto.ts +39 -1
  36. package/src/escrow-enrollment-intent.test.ts +224 -0
  37. package/src/escrow-enrollment-intent.ts +236 -0
  38. package/src/escrow-enrollment-intent.types.ts +28 -0
  39. package/src/escrow-nitro-attestation.test.ts +72 -0
  40. package/src/escrow-nitro-attestation.ts +34 -3
  41. package/src/index.ts +27 -0
  42. package/src/sss-authority.test.ts +889 -0
  43. package/src/sss-strategy.ts +479 -38
  44. package/src/types.ts +22 -3
@@ -28,13 +28,22 @@ export interface EscrowEnvelope {
28
28
  ciphertext: string;
29
29
  }
30
30
 
31
- /** Recovery share bound to an identity and a positive share version. */
31
+ /** P9.1 (attack C): the app's compiled template languages. Keep in lockstep with
32
+ * apps/learn-card-app/src/i18n's SUPPORTED_LANGUAGES and the Rust RECOVERY_LANGUAGES. */
33
+ export const RECOVERY_LANGUAGES = ['en', 'es', 'fr', 'ar'] as const;
34
+
35
+ /** Recovery share bound to an identity and a positive share version.
36
+ * `recoveryEmail`/`recoveryLanguage`: bound by the user's own device at
37
+ * enrollment, never by lca-api. Always both-present or both-absent; changing
38
+ * the address requires a fresh `replace` enrollment, not an edit. */
32
39
  export interface EscrowBlobPlaintext {
33
40
  version: typeof ESCROW_ENVELOPE_VERSION;
34
41
  recoveryShare: string;
35
42
  did: string;
36
43
  shareVersion: number;
37
44
  pinVerifier?: string;
45
+ recoveryEmail?: string;
46
+ recoveryLanguage?: string;
38
47
  }
39
48
 
40
49
  /** Released recovery share additionally bound to its recovery hold. */
@@ -105,6 +114,34 @@ export const parseEscrowBlobPlaintext = (value: unknown): EscrowBlobPlaintext =>
105
114
  throw new Error('Invalid escrow shareVersion');
106
115
  }
107
116
 
117
+ // P9.1: paired requirement; deliberately conservative email shape (not full
118
+ // RFC 5322/IDN), matching the enclave's `normalize()` exactly.
119
+ if ((value.recoveryEmail !== undefined) !== (value.recoveryLanguage !== undefined)) {
120
+ throw new Error('Invalid escrow recoveryEmail');
121
+ }
122
+ let recoveryContact: Pick<EscrowBlobPlaintext, 'recoveryEmail' | 'recoveryLanguage'> = {};
123
+ if (value.recoveryEmail !== undefined) {
124
+ const email = requireString(value, 'recoveryEmail', { min: 3, max: 254 })
125
+ .trim()
126
+ .toLowerCase();
127
+ const at = email.split('@');
128
+ if (
129
+ !/^[\x21-\x7e]+$/.test(email) ||
130
+ at.length !== 2 ||
131
+ at[0].length === 0 ||
132
+ at[1].length === 0 ||
133
+ !at[1].includes('.') ||
134
+ at[1].startsWith('.') ||
135
+ at[1].endsWith('.')
136
+ ) {
137
+ throw new Error('Invalid escrow recoveryEmail');
138
+ }
139
+ const language = requireString(value, 'recoveryLanguage', { min: 2, max: 2 });
140
+ if (!(RECOVERY_LANGUAGES as readonly string[]).includes(language)) {
141
+ throw new Error('Invalid escrow recoveryLanguage');
142
+ }
143
+ recoveryContact = { recoveryEmail: email, recoveryLanguage: language };
144
+ }
108
145
  return {
109
146
  version: ESCROW_ENVELOPE_VERSION,
110
147
  recoveryShare: requireString(value, 'recoveryShare', {
@@ -123,6 +160,7 @@ export const parseEscrowBlobPlaintext = (value: unknown): EscrowBlobPlaintext =>
123
160
  }).toLowerCase(),
124
161
  }
125
162
  : {}),
163
+ ...recoveryContact,
126
164
  };
127
165
  };
128
166
 
@@ -0,0 +1,224 @@
1
+ // @vitest-environment node
2
+ import { readFileSync } from 'node:fs';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { verifyAsync } from '@noble/ed25519';
5
+ import {
6
+ encodeEscrowEnrollmentIntent,
7
+ signEscrowEnrollmentIntent,
8
+ getEscrowBlobIdentity,
9
+ } from './escrow-enrollment-intent';
10
+ import type {
11
+ EscrowEnrollmentIntent,
12
+ SignedEscrowEnrollmentIntent,
13
+ } from './escrow-enrollment-intent.types';
14
+ import type { EscrowEnvelope } from './escrow-crypto';
15
+
16
+ interface Vector extends SignedEscrowEnrollmentIntent {
17
+ seedHex: string;
18
+ publicKeyHex: string;
19
+ canonicalHex: string;
20
+ sourceFixtureIndex: number;
21
+ targetFixtureIndex: number;
22
+ }
23
+ const vector: Vector = JSON.parse(
24
+ readFileSync(
25
+ new URL('./__fixtures__/escrow-enrollment-intent-v1.json', import.meta.url),
26
+ 'utf8'
27
+ )
28
+ );
29
+ const envelopes: { blobs: { envelope: EscrowEnvelope }[] } = JSON.parse(
30
+ readFileSync(new URL('./__fixtures__/escrow-vectors.json', import.meta.url), 'utf8')
31
+ );
32
+
33
+ describe('owner-signed escrow enrollment intent', () => {
34
+ it('matches the shared Rust message, raw Ed25519 signature and envelope identities', async () => {
35
+ const message = encodeEscrowEnrollmentIntent(vector.intent);
36
+ expect(Buffer.from(message).toString('hex')).toBe(vector.canonicalHex);
37
+ expect(await signEscrowEnrollmentIntent(vector.intent, vector.seedHex)).toEqual({
38
+ intent: vector.intent,
39
+ signature: vector.signature,
40
+ });
41
+ expect(
42
+ await verifyAsync(
43
+ Buffer.from(vector.signature, 'base64'),
44
+ message,
45
+ vector.publicKeyHex,
46
+ { zip215: false }
47
+ )
48
+ ).toBe(true);
49
+ expect(
50
+ await getEscrowBlobIdentity(envelopes.blobs[vector.sourceFixtureIndex].envelope)
51
+ ).toBe(vector.intent.sourceBlobIdentity);
52
+ expect(
53
+ await getEscrowBlobIdentity(envelopes.blobs[vector.targetFixtureIndex].envelope)
54
+ ).toBe(vector.intent.targetBlobIdentity);
55
+ });
56
+
57
+ it('refuses a nonowner seed and never needs an external DID resolver', async () => {
58
+ await expect(signEscrowEnrollmentIntent(vector.intent, '99'.repeat(32))).rejects.toThrow();
59
+ for (const did of [
60
+ 'did:web:attacker.test',
61
+ `${vector.intent.did}#key`,
62
+ 'did:key:uZm9v',
63
+ vector.intent.did.replace('z6Mk', 'z0Mk'),
64
+ ]) {
65
+ expect(() => encodeEscrowEnrollmentIntent({ ...vector.intent, did })).toThrow();
66
+ }
67
+ await expect(
68
+ signEscrowEnrollmentIntent(vector.intent, vector.seedHex + '00')
69
+ ).rejects.toThrow();
70
+ await expect(
71
+ signEscrowEnrollmentIntent(vector.intent, vector.seedHex + '\n')
72
+ ).rejects.toThrow();
73
+ });
74
+
75
+ it('snapshots intent before awaiting so caller mutation cannot change signed return data', async () => {
76
+ const intent = { ...vector.intent };
77
+ const pending = signEscrowEnrollmentIntent(intent, vector.seedHex);
78
+ intent.tenant = 'attacker';
79
+ intent.allowRewrap = false;
80
+ const signed = await pending;
81
+ expect(signed.intent).toEqual(vector.intent);
82
+ expect(signed.signature).toBe(vector.signature);
83
+ });
84
+
85
+ it.each([
86
+ { tenant: 'other' },
87
+ { authorityGeneration: 'g3' },
88
+ { expectedEnrollmentVersion: 8 },
89
+ { action: 'replace', recoveryEmail: 'owner@example.com', recoveryLanguage: 'en' },
90
+ { sourceBlobIdentity: '11'.repeat(32) },
91
+ { targetBlobIdentity: '22'.repeat(32) },
92
+ { shareVersion: 43 },
93
+ { enrollmentEpoch: 5 },
94
+ { requestId: 'other' },
95
+ { allowRewrap: false },
96
+ ] satisfies Partial<EscrowEnrollmentIntent>[])(
97
+ 'binds signed field mutation %j',
98
+ async mutation => {
99
+ const message = encodeEscrowEnrollmentIntent({ ...vector.intent, ...mutation });
100
+ expect(
101
+ await verifyAsync(
102
+ Buffer.from(vector.signature, 'base64'),
103
+ message,
104
+ vector.publicKeyHex,
105
+ { zip215: false }
106
+ )
107
+ ).toBe(false);
108
+ }
109
+ );
110
+
111
+ it.each([
112
+ { version: 1 },
113
+ { version: 3 },
114
+ { tenant: '' },
115
+ { tenant: 'a'.repeat(129) },
116
+ { tenant: 'tenant\0evil' },
117
+ { tenant: 'tenant\n' },
118
+ { requestId: 'request\u2028' },
119
+ { targetBlobIdentity: 'ab'.repeat(32) + '\n' },
120
+ { expectedEnrollmentVersion: -1 },
121
+ { expectedEnrollmentVersion: Number.MAX_SAFE_INTEGER + 1 },
122
+ { expectedEnrollmentVersion: 1.5 },
123
+ { shareVersion: 0 },
124
+ { shareVersion: 2 ** 32 },
125
+ { enrollmentEpoch: 0 },
126
+ { enrollmentEpoch: Infinity },
127
+ { sourceBlobIdentity: undefined },
128
+ { targetBlobIdentity: 'AB'.repeat(32) },
129
+ { requestId: 'x#y' },
130
+ { allowRewrap: undefined },
131
+ { action: 'rewrap' },
132
+ { pinAction: 'clear' },
133
+ { extra: 'unsigned-field' },
134
+ { recoveryLanguage: 'en' },
135
+ { recoveryEmail: 'owner@example.com' },
136
+ {
137
+ action: 'enroll',
138
+ sourceBlobIdentity: null,
139
+ recoveryEmail: 'owner@example.com',
140
+ recoveryLanguage: 'xx',
141
+ },
142
+ {
143
+ action: 'enroll',
144
+ sourceBlobIdentity: null,
145
+ recoveryEmail: 'not-an-email',
146
+ recoveryLanguage: 'en',
147
+ },
148
+ { recoveryEmail: 'owner@example.com', recoveryLanguage: 'en' },
149
+ ])('rejects invalid wire input %j', mutation => {
150
+ // Exercise runtime guards, rather than pretending unknown input is typed.
151
+ const input: unknown = { ...vector.intent, ...mutation };
152
+ expect(() => encodeEscrowEnrollmentIntent(input as EscrowEnrollmentIntent)).toThrow();
153
+ });
154
+
155
+ it('accepts safe-integer boundaries and explicit nulls, not omitted fields', () => {
156
+ const intent: EscrowEnrollmentIntent = {
157
+ ...vector.intent,
158
+ action: 'enroll',
159
+ sourceBlobIdentity: null,
160
+ expectedEnrollmentVersion: Number.MAX_SAFE_INTEGER,
161
+ enrollmentEpoch: Number.MAX_SAFE_INTEGER,
162
+ shareVersion: 0xffffffff,
163
+ recoveryEmail: 'owner@example.com',
164
+ recoveryLanguage: 'en',
165
+ };
166
+ expect(() => encodeEscrowEnrollmentIntent(intent)).not.toThrow();
167
+ const { sourceBlobIdentity: omitted, ...missing } = intent;
168
+ expect(omitted).toBeNull();
169
+ expect(() => encodeEscrowEnrollmentIntent(missing as EscrowEnrollmentIntent)).toThrow();
170
+ });
171
+
172
+ it('pins action/PIN enum tags and separates disable from enrollment', () => {
173
+ const disable: EscrowEnrollmentIntent = {
174
+ ...vector.intent,
175
+ action: 'disable',
176
+ targetBlobIdentity: null,
177
+ allowRewrap: false,
178
+ };
179
+ expect(() => encodeEscrowEnrollmentIntent(disable)).not.toThrow();
180
+ expect(() => encodeEscrowEnrollmentIntent({ ...disable, allowRewrap: true })).toThrow();
181
+ for (const pinAction of ['set', 'change', 'clear', 'keep'] as const) {
182
+ expect(() =>
183
+ encodeEscrowEnrollmentIntent({
184
+ ...vector.intent,
185
+ action: 'replace',
186
+ pinAction,
187
+ recoveryEmail: 'owner@example.com',
188
+ recoveryLanguage: 'en',
189
+ })
190
+ ).not.toThrow();
191
+ }
192
+ expect(() =>
193
+ encodeEscrowEnrollmentIntent({
194
+ ...vector.intent,
195
+ targetBlobIdentity: vector.intent.sourceBlobIdentity,
196
+ })
197
+ ).toThrow();
198
+ });
199
+
200
+ it('preserves blob identity under Q/-Q, but not ciphertext substitution', async () => {
201
+ const original = envelopes.blobs[0].envelope;
202
+ const point = Buffer.from(original.ephemeralPublicKey, 'base64');
203
+ const prime = BigInt('0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff');
204
+ const y = BigInt(`0x${point.subarray(33).toString('hex')}`);
205
+ Buffer.from((prime - y).toString(16).padStart(64, '0'), 'hex').copy(point, 33);
206
+ expect(
207
+ await getEscrowBlobIdentity({
208
+ ...original,
209
+ ephemeralPublicKey: point.toString('base64'),
210
+ })
211
+ ).toBe(vector.intent.sourceBlobIdentity);
212
+ const ciphertext = Buffer.from(original.ciphertext, 'base64');
213
+ ciphertext[0] ^= 1;
214
+ expect(
215
+ await getEscrowBlobIdentity({ ...original, ciphertext: ciphertext.toString('base64') })
216
+ ).not.toBe(vector.intent.sourceBlobIdentity);
217
+ await expect(
218
+ getEscrowBlobIdentity({
219
+ ...original,
220
+ ephemeralPublicKey: Buffer.alloc(65, 4).toString('base64'),
221
+ })
222
+ ).rejects.toThrow();
223
+ });
224
+ });
@@ -0,0 +1,236 @@
1
+ import { ExtendedPoint, getPublicKeyAsync, signAsync } from '@noble/ed25519';
2
+ import { base64ToBuffer, bufferToBase64, bytesToHex, hexToBytes } from './crypto';
3
+ import { parseEscrowEnvelope } from './escrow-crypto';
4
+ import type { EscrowEnvelope } from './escrow-crypto';
5
+ import type {
6
+ EscrowEnrollmentIntent,
7
+ SignedEscrowEnrollmentIntent,
8
+ } from './escrow-enrollment-intent.types';
9
+
10
+ // P9.1: bumped alongside `version` so the two new recovery fields can never be
11
+ // verified against the pre-PR3 byte layout. Must match Rust's INTENT_DOMAIN exactly.
12
+ export const ESCROW_ENROLLMENT_INTENT_DOMAIN = 'learncard-escrow-enrollment-intent-v2\0';
13
+ const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
14
+ const FIELDS = [
15
+ 'version',
16
+ 'tenant',
17
+ 'did',
18
+ 'authorityGeneration',
19
+ 'expectedEnrollmentVersion',
20
+ 'action',
21
+ 'sourceBlobIdentity',
22
+ 'targetBlobIdentity',
23
+ 'pinAction',
24
+ 'shareVersion',
25
+ 'enrollmentEpoch',
26
+ 'requestId',
27
+ 'allowRewrap',
28
+ 'recoveryEmail',
29
+ 'recoveryLanguage',
30
+ ];
31
+ const ACTIONS = ['enroll', 'replace', 'disable', 'carry'];
32
+ const PIN_ACTIONS = ['set', 'change', 'clear', 'keep'];
33
+ // P9.1 (attack C): the app's compiled template languages. Keep in lockstep with
34
+ // apps/learn-card-app/src/i18n's SUPPORTED_LANGUAGES and the Rust RECOVERY_LANGUAGES.
35
+ const RECOVERY_LANGUAGES = ['en', 'es', 'fr', 'ar'];
36
+
37
+ const requireIntent = (condition: boolean): void => {
38
+ if (!condition) throw new Error('Invalid escrow enrollment intent');
39
+ };
40
+ // JS `$` also matches before a final newline: require a complete match.
41
+ const matches = (value: unknown, pattern: RegExp): boolean =>
42
+ typeof value === 'string' && pattern.exec(value)?.[0] === value;
43
+ const utf8 = (value: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(value);
44
+ const integer = (value: number, width: 4 | 8): Uint8Array<ArrayBuffer> => {
45
+ requireIntent(
46
+ Number.isSafeInteger(value) && value >= 0 && (width === 8 || value <= 0xffffffff)
47
+ );
48
+ const bytes = new Uint8Array(width);
49
+ const view = new DataView(bytes.buffer);
50
+ if (width === 4) view.setUint32(0, value, false);
51
+ else view.setBigUint64(0, BigInt(value), false);
52
+ return bytes;
53
+ };
54
+ const join = (fields: Uint8Array[]): Uint8Array<ArrayBuffer> => {
55
+ const result = new Uint8Array(fields.reduce((length, field) => length + field.length, 0));
56
+ let offset = 0;
57
+ for (const field of fields) {
58
+ result.set(field, offset);
59
+ offset += field.length;
60
+ }
61
+ return result;
62
+ };
63
+ const hashBytes = (value: string | null): Uint8Array<ArrayBuffer> => {
64
+ if (value === null) return new Uint8Array();
65
+ requireIntent(matches(value, /^[0-9a-f]{64}$/));
66
+ return new Uint8Array(hexToBytes(value));
67
+ };
68
+
69
+ /** Fixed-size Ed25519 multicodec decoder, not a network DID resolver. */
70
+ const didPublicKey = (did: string): Uint8Array<ArrayBuffer> => {
71
+ requireIntent(typeof did === 'string' && did.startsWith('did:key:z') && did.length === 56);
72
+ let value = 0n;
73
+ for (const char of did.slice(9)) {
74
+ const digit = BASE58.indexOf(char);
75
+ requireIntent(digit >= 0);
76
+ value = value * 58n + BigInt(digit);
77
+ }
78
+ const bytes = new Uint8Array(34);
79
+ for (let i = 33; i >= 0; i--) {
80
+ bytes[i] = Number(value & 255n);
81
+ value >>= 8n;
82
+ }
83
+ // Fixed 47-digit length plus nonzero ed01 prefix excludes leading-zero aliases.
84
+ requireIntent(value === 0n && bytes[0] === 0xed && bytes[1] === 0x01);
85
+ const key = bytes.slice(2);
86
+ try {
87
+ requireIntent(!ExtendedPoint.fromHex(key, false).isSmallOrder());
88
+ } catch {
89
+ throw new Error('Invalid escrow enrollment intent');
90
+ }
91
+ return key;
92
+ };
93
+
94
+ /** Produce the exact Rust-verified V1 message, never JSON or a prehashed signature. */
95
+ export const encodeEscrowEnrollmentIntent = (
96
+ intent: EscrowEnrollmentIntent
97
+ ): Uint8Array<ArrayBuffer> => {
98
+ requireIntent(typeof intent === 'object' && intent !== null && !Array.isArray(intent));
99
+ requireIntent(
100
+ Object.keys(intent).length === FIELDS.length &&
101
+ FIELDS.every(field => Object.prototype.hasOwnProperty.call(intent, field))
102
+ );
103
+ requireIntent(intent.version === 2 && typeof intent.allowRewrap === 'boolean');
104
+ requireIntent(matches(intent.tenant, /^[A-Za-z0-9_-]{1,128}$/));
105
+ requireIntent(matches(intent.authorityGeneration, /^[A-Za-z0-9_-]{1,128}$/));
106
+ requireIntent(matches(intent.requestId, /^[A-Za-z0-9._-]{1,112}$/));
107
+ didPublicKey(intent.did);
108
+ const action = ACTIONS.indexOf(intent.action);
109
+ const pin = PIN_ACTIONS.indexOf(intent.pinAction);
110
+ requireIntent(action >= 0 && pin >= 0 && intent.shareVersion > 0 && intent.enrollmentEpoch > 0);
111
+ const source = hashBytes(intent.sourceBlobIdentity);
112
+ const target = hashBytes(intent.targetBlobIdentity);
113
+ if (intent.action === 'enroll') requireIntent(source.length === 0 && target.length === 32);
114
+ else if (intent.action === 'disable')
115
+ requireIntent(
116
+ source.length === 32 &&
117
+ target.length === 0 &&
118
+ intent.pinAction === 'keep' &&
119
+ !intent.allowRewrap
120
+ );
121
+ else
122
+ requireIntent(
123
+ source.length === 32 &&
124
+ target.length === 32 &&
125
+ intent.sourceBlobIdentity !== intent.targetBlobIdentity
126
+ );
127
+ if (intent.action === 'carry') requireIntent(intent.pinAction === 'keep');
128
+ // P9.1 (attack C): email/language are deliberately conservative (not full
129
+ // RFC 5322/IDN) and paired; Enroll/Replace require them, Disable/Carry forbid them.
130
+ if (intent.recoveryEmail === null) {
131
+ requireIntent(
132
+ intent.recoveryLanguage === null &&
133
+ (intent.action === 'disable' || intent.action === 'carry')
134
+ );
135
+ } else {
136
+ requireIntent(intent.action === 'enroll' || intent.action === 'replace');
137
+ requireIntent(
138
+ matches(intent.recoveryEmail, /^[\x21-\x7e]{3,254}$/) &&
139
+ intent.recoveryEmail === intent.recoveryEmail.trim().toLowerCase()
140
+ );
141
+ const at = intent.recoveryEmail.split('@');
142
+ requireIntent(
143
+ at.length === 2 &&
144
+ at[0].length > 0 &&
145
+ at[1].length > 0 &&
146
+ at[1].includes('.') &&
147
+ !at[1].startsWith('.') &&
148
+ !at[1].endsWith('.')
149
+ );
150
+ requireIntent(
151
+ intent.recoveryLanguage !== null && RECOVERY_LANGUAGES.includes(intent.recoveryLanguage)
152
+ );
153
+ }
154
+ const fields = [
155
+ integer(intent.version, 4),
156
+ utf8(intent.tenant),
157
+ utf8(intent.did),
158
+ utf8(intent.authorityGeneration),
159
+ integer(intent.expectedEnrollmentVersion, 8),
160
+ Uint8Array.of(action),
161
+ source,
162
+ target,
163
+ Uint8Array.of(pin),
164
+ integer(intent.shareVersion, 4),
165
+ integer(intent.enrollmentEpoch, 8),
166
+ utf8(intent.requestId),
167
+ Uint8Array.of(Number(intent.allowRewrap)),
168
+ intent.recoveryEmail === null ? new Uint8Array() : utf8(intent.recoveryEmail),
169
+ intent.recoveryLanguage === null ? new Uint8Array() : utf8(intent.recoveryLanguage),
170
+ ];
171
+ return join([
172
+ utf8(ESCROW_ENROLLMENT_INTENT_DOMAIN),
173
+ ...fields.flatMap(field => [integer(field.length, 4), field]),
174
+ ]);
175
+ };
176
+
177
+ /** Sign with the account's 32-byte Ed25519 seed. Never needed for recovery itself.
178
+ * The caller must obtain generation/current version from an attested authority
179
+ * response and construct the exact operation it intends; signing is not consent UI.
180
+ */
181
+ export const signEscrowEnrollmentIntent = async (
182
+ intent: EscrowEnrollmentIntent,
183
+ privateKey: string
184
+ ): Promise<SignedEscrowEnrollmentIntent> => {
185
+ // Snapshot before the first await so caller mutation cannot alter returned intent.
186
+ encodeEscrowEnrollmentIntent(intent);
187
+ const snapshot = { ...intent };
188
+ const message = encodeEscrowEnrollmentIntent(snapshot);
189
+ requireIntent(matches(privateKey, /^[0-9a-f]{64}$/i));
190
+ const seed = hexToBytes(privateKey);
191
+ try {
192
+ const publicKey = await getPublicKeyAsync(seed);
193
+ const expected = didPublicKey(snapshot.did);
194
+ requireIntent(publicKey.every((byte, index) => byte === expected[index]));
195
+ const signature = await signAsync(message, seed);
196
+ return { intent: snapshot, signature: bufferToBase64(new Uint8Array(signature).buffer) };
197
+ } finally {
198
+ seed.fill(0);
199
+ }
200
+ };
201
+
202
+ /** Canonical blob identity matches Rust, including Q/-Q ECDH equivalence. */
203
+ export const getEscrowBlobIdentity = async (input: EscrowEnvelope): Promise<string> => {
204
+ const envelope = parseEscrowEnvelope(input);
205
+ const decode = (value: string): Uint8Array<ArrayBuffer> => {
206
+ const bytes = new Uint8Array(base64ToBuffer(value));
207
+ requireIntent(bufferToBase64(bytes.buffer) === value);
208
+ return bytes;
209
+ };
210
+ const point = decode(envelope.ephemeralPublicKey);
211
+ const salt = decode(envelope.salt);
212
+ const iv = decode(envelope.iv);
213
+ const ciphertext = decode(envelope.ciphertext);
214
+ requireIntent(
215
+ point.length === 65 &&
216
+ point[0] === 4 &&
217
+ salt.length === 32 &&
218
+ iv.length === 12 &&
219
+ ciphertext.length >= 16
220
+ );
221
+ await crypto.subtle.importKey('raw', point, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
222
+ const fields = [
223
+ Uint8Array.of(1),
224
+ utf8(envelope.algorithm),
225
+ utf8(envelope.keyId),
226
+ point.slice(1, 33),
227
+ salt,
228
+ iv,
229
+ ciphertext,
230
+ ];
231
+ const message = join([
232
+ utf8('learncard-escrow-blob-id-v1\0'),
233
+ ...fields.flatMap(field => [integer(field.length, 8), field]),
234
+ ]);
235
+ return bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', message)));
236
+ };
@@ -0,0 +1,28 @@
1
+ /** V2 owner authorization, distinct from authentication and from recovery. */
2
+ export interface EscrowEnrollmentIntent {
3
+ version: 2;
4
+ tenant: string;
5
+ did: string;
6
+ authorityGeneration: string;
7
+ expectedEnrollmentVersion: number;
8
+ action: 'enroll' | 'replace' | 'disable' | 'carry';
9
+ sourceBlobIdentity: string | null;
10
+ targetBlobIdentity: string | null;
11
+ pinAction: 'set' | 'change' | 'clear' | 'keep';
12
+ shareVersion: number;
13
+ enrollmentEpoch: number;
14
+ requestId: string;
15
+ allowRewrap: boolean;
16
+ /** P9.1 (attack C): required (and must match the blob) on enroll/replace;
17
+ * must be null on disable/carry (the enclave carries the prior value forward).
18
+ * Changing the address requires a fresh signed `replace` intent, not an edit. */
19
+ recoveryEmail: string | null;
20
+ /** One of the app's compiled template languages (currently en/es/fr/ar). */
21
+ recoveryLanguage: string | null;
22
+ }
23
+
24
+ /** Signature is canonical padded standard base64 of raw Ed25519 R || S. */
25
+ export interface SignedEscrowEnrollmentIntent {
26
+ intent: EscrowEnrollmentIntent;
27
+ signature: string;
28
+ }
@@ -202,3 +202,75 @@ describe('FakeNsm Nitro attestation', () => {
202
202
  });
203
203
  });
204
204
  });
205
+
206
+ describe('real AWS Nitro attestation', () => {
207
+ const real = JSON.parse(
208
+ readFileSync(
209
+ new URL(
210
+ './__fixtures__/nitro-attestation-real/staging-nsm-document.json',
211
+ import.meta.url
212
+ ),
213
+ 'utf8'
214
+ )
215
+ ) as {
216
+ nonceHex: string;
217
+ timestamp: number;
218
+ keyId: string;
219
+ measurements: { pcr0: string; pcr1: string; pcr2: string };
220
+ escrowPublicKeySpkiB64: string;
221
+ measurementHash: string;
222
+ document: string;
223
+ };
224
+ const realNonce = Uint8Array.from(real.nonceHex.match(/../g)!, byte => parseInt(byte, 16));
225
+ const realOptions = {
226
+ expectedNonce: realNonce,
227
+ policy: { mode: 'nitro' as const, pinnedMeasurements: [real.measurements] },
228
+ now: real.timestamp,
229
+ };
230
+
231
+ it('uses the indefinite-length payload map the NSM actually emits', () => {
232
+ const cose = decode(base64ToBuffer(real.document), { useMaps: true }) as unknown[];
233
+ expect((cose[2] as Uint8Array)[0]).toBe(0xbf);
234
+ });
235
+
236
+ it('verifies against the default AWS root, nonce and pinned measurements', async () => {
237
+ await expect(
238
+ verifyNitroAttestationDocument(real.document, realOptions)
239
+ ).resolves.toMatchObject({
240
+ keyId: real.keyId,
241
+ escrowPublicKeySpkiB64: real.escrowPublicKeySpkiB64,
242
+ measurementHash: real.measurementHash,
243
+ timestamp: real.timestamp,
244
+ });
245
+ });
246
+
247
+ it('still rejects a real document with a different nonce or unpinned measurements', async () => {
248
+ await expect(
249
+ verifyNitroAttestationDocument(real.document, {
250
+ ...realOptions,
251
+ expectedNonce: new Uint8Array(32),
252
+ })
253
+ ).rejects.toMatchObject({ reason: 'nonce' });
254
+ await expect(
255
+ verifyNitroAttestationDocument(real.document, {
256
+ ...realOptions,
257
+ policy: { mode: 'nitro', pinnedMeasurements: [measurement] },
258
+ })
259
+ ).rejects.toMatchObject({ reason: 'pcr' });
260
+ });
261
+
262
+ it('keeps rejecting indefinite-length encoding outside the signed payload', async () => {
263
+ const cose = decode(base64ToBuffer(real.document), { useMaps: true }) as unknown[];
264
+ const indefiniteEnvelope = new Uint8Array([
265
+ 0x9f,
266
+ ...encode(cose[0]),
267
+ ...encode(cose[1]),
268
+ ...encode(cose[2]),
269
+ ...encode(cose[3]),
270
+ 0xff,
271
+ ]);
272
+ await expect(
273
+ verifyNitroAttestationDocument(bufferToBase64(indefiniteEnvelope.buffer), realOptions)
274
+ ).rejects.toMatchObject({ reason: 'cbor' });
275
+ });
276
+ });