@learncard/sss-key-manager 0.2.7 → 0.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.
- package/README.md +9 -0
- package/dist/escrow-authority.d.ts +4 -3
- package/dist/escrow-authority.d.ts.map +1 -1
- package/dist/escrow-authority.types.d.ts +15 -0
- package/dist/escrow-authority.types.d.ts.map +1 -1
- package/dist/escrow-crypto.d.ts +19 -1
- package/dist/escrow-crypto.d.ts.map +1 -1
- package/dist/escrow-enrollment-intent.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +720 -73
- package/dist/sss-key-manager.cjs.development.js.map +3 -3
- package/dist/sss-key-manager.cjs.production.min.js +30 -30
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +722 -73
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts +26 -0
- package/dist/sss-strategy.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__fixtures__/escrow-authority-brand-names-v1.json +29 -0
- package/src/__fixtures__/escrow-carry-contact-contract-v1.json +25 -1
- package/src/__fixtures__/escrow-email-brand-contract-v1.json +225 -0
- package/src/escrow-authority.test.ts +2 -0
- package/src/escrow-authority.ts +24 -7
- package/src/escrow-authority.types.ts +27 -0
- package/src/escrow-crypto.test.ts +75 -0
- package/src/escrow-crypto.ts +43 -1
- package/src/escrow-enrollment-intent.ts +2 -1
- package/src/index.ts +8 -1
- package/src/sss-authority.test.ts +392 -32
- package/src/sss-strategy.ts +66 -8
package/dist/sss-strategy.d.ts
CHANGED
|
@@ -37,6 +37,32 @@ export interface SSSStrategyConfig {
|
|
|
37
37
|
escrow?: {
|
|
38
38
|
enabled: boolean;
|
|
39
39
|
attestation: EscrowAttestationPolicy;
|
|
40
|
+
/**
|
|
41
|
+
* The escrow authority's tenant label: the enclave's measured
|
|
42
|
+
* `ESCROW_AUTHORITY_TENANT`, which it signs into every authority record and
|
|
43
|
+
* requires in every signed enrollment intent. Used for everything the
|
|
44
|
+
* enclave verifies (intent tenant, authority state, hold, code-delivery and
|
|
45
|
+
* persisted recovery expectations). Everything else (X-Tenant-Id, email
|
|
46
|
+
* branding, same-tenant guards) keeps using {@link SSSStrategyConfig.tenantId}.
|
|
47
|
+
*
|
|
48
|
+
* Defaults to `tenantId` (then `'learncard'`). Set it explicitly when this app
|
|
49
|
+
* shares another tenant's single-tenant enclave, e.g. VetPass on LearnCard's
|
|
50
|
+
* (`'learncard'`). Must match `/^[A-Za-z0-9_-]{1,128}$/`.
|
|
51
|
+
*/
|
|
52
|
+
authorityTenant?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Display name sealed into the escrow blob for enclave-sent emails
|
|
55
|
+
* (D21, `recoveryBrand`), e.g. `'VetPass'`. Sealed only when the
|
|
56
|
+
* recovery email and language are (nitro enroll/replace), never on a
|
|
57
|
+
* carry. Unset: the enclave brands emails from its measured tenant label.
|
|
58
|
+
*
|
|
59
|
+
* Set only after every live enclave host runs the brand-aware image: an
|
|
60
|
+
* older image drops the field whenever it re-encrypts the blob.
|
|
61
|
+
* Must pass {@link canonicalEscrowEmailBrand} (ASCII letters, digits,
|
|
62
|
+
* space, `-`, `'`, `&`; 1-32 characters); `createSSSStrategy` throws
|
|
63
|
+
* otherwise.
|
|
64
|
+
*/
|
|
65
|
+
emailBrand?: string;
|
|
40
66
|
};
|
|
41
67
|
/** Report enrollment failures without logging recovery material. */
|
|
42
68
|
onEscrowError?: (err: unknown) => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sss-strategy.d.ts","sourceRoot":"","sources":["../src/sss-strategy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EACR,wBAAwB,EAaxB,uBAAuB,EAM1B,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"sss-strategy.d.ts","sourceRoot":"","sources":["../src/sss-strategy.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/sss-key-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shamir Secret Sharing key manager for LearnCard - replaces Web3Auth SFA",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/sss-key-manager.esm.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"vitest": "4.1.11"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@learncard/types": "
|
|
42
|
+
"@learncard/types": "6.0.0",
|
|
43
43
|
"@noble/ed25519": "^2.0.0",
|
|
44
44
|
"@peculiar/x509": "^2.1.0",
|
|
45
45
|
"@scure/bip39": "^1.2.1",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Authority label -> fallback email brand. Rust brand_name (escrow-enclave-app/src/authority/email.rs) and TS escrowAuthorityBrandName (learn-card-base/src/config/authConfig.ts) must both map every case exactly; changing either side means updating this file.",
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"label": "learncard",
|
|
6
|
+
"brand": "LearnCard"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"label": "scouts",
|
|
10
|
+
"brand": "ScoutPass"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"label": "vetpass",
|
|
14
|
+
"brand": "vetpass"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "other",
|
|
18
|
+
"brand": "other"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "LearnCard",
|
|
22
|
+
"brand": "LearnCard"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "scoutpass",
|
|
26
|
+
"brand": "scoutpass"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "P2-d cross-language contract: the enclave authority policy (apply_enrollment_intent, authority.rs) rejects a Carry target blob whose PLAINTEXT already carries a non-null recoveryEmail/recoveryLanguage — on carry the enclave always fills those fields in from the source blob instead — but accepts the identical blob shape for Enroll/Replace (where the signed intent is required to bind a matching contact instead). Rust (policy/authority/tests.rs) builds and submits a real target blob with each case's fields and asserts apply_enrollment_intent's accept/reject outcome through the real enclave policy; TypeScript (escrow-authority.test.ts) asserts the same outcome via resolveEscrowEnrollmentAction plus the same rule the enclave enforces. Each case is independent: did/shareVersion/epoch are illustrative, not cross-referenced. recoveryEmail/recoveryLanguage are always both-null or both-set, matching the blob plaintext's own format invariant.",
|
|
2
|
+
"description": "P2-d cross-language contract: the enclave authority policy (apply_enrollment_intent, authority.rs) rejects a Carry target blob whose PLAINTEXT already carries a non-null recoveryEmail/recoveryLanguage — on carry the enclave always fills those fields in from the source blob instead — but accepts the identical blob shape for Enroll/Replace (where the signed intent is required to bind a matching contact instead). Rust (policy/authority/tests.rs) builds and submits a real target blob with each case's fields and asserts apply_enrollment_intent's accept/reject outcome through the real enclave policy; TypeScript (escrow-authority.test.ts) asserts the same outcome via resolveEscrowEnrollmentAction plus the same rule the enclave enforces. Each case is independent: did/shareVersion/epoch are illustrative, not cross-referenced. recoveryEmail/recoveryLanguage are always both-null or both-set, matching the blob plaintext's own format invariant. D21: blobRecoveryBrand (optional; absent means null) follows the same Carry rule: a Carry target that claims a sealed email brand is refused (the enclave inherits the source's), while Enroll/Replace keep whatever brand the owner sealed (the brand is not in the signed intent).",
|
|
3
3
|
"cases": [
|
|
4
4
|
{
|
|
5
5
|
"name": "enroll accepts a target with contact fields",
|
|
@@ -28,6 +28,30 @@
|
|
|
28
28
|
"blobRecoveryEmail": null,
|
|
29
29
|
"blobRecoveryLanguage": null,
|
|
30
30
|
"expected": "accept"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "enroll accepts a target with contact fields and a sealed brand",
|
|
34
|
+
"action": "enroll",
|
|
35
|
+
"blobRecoveryEmail": "owner@example.com",
|
|
36
|
+
"blobRecoveryLanguage": "en",
|
|
37
|
+
"blobRecoveryBrand": "VetPass",
|
|
38
|
+
"expected": "accept"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "replace accepts a target with contact fields and a sealed brand",
|
|
42
|
+
"action": "replace",
|
|
43
|
+
"blobRecoveryEmail": "owner@example.com",
|
|
44
|
+
"blobRecoveryLanguage": "en",
|
|
45
|
+
"blobRecoveryBrand": "VetPass",
|
|
46
|
+
"expected": "accept"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "carry rejects a target that carries only a sealed brand",
|
|
50
|
+
"action": "carry",
|
|
51
|
+
"blobRecoveryEmail": null,
|
|
52
|
+
"blobRecoveryLanguage": null,
|
|
53
|
+
"blobRecoveryBrand": "VetPass",
|
|
54
|
+
"expected": "reject"
|
|
31
55
|
}
|
|
32
56
|
]
|
|
33
57
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "D21 cross-language contract for the owner-sealed escrow email brand (recoveryBrand). Rust canonical_brand (services/escrow-enclave-app/src/crypto.rs) and TypeScript canonicalEscrowEmailBrand (escrow-crypto.ts) must map every input to the same canonical form, or both reject it (expected: null). Rules: trim and collapse runs of ASCII space (0x20) only; any other whitespace or control character rejects; allowed characters are A-Z a-z 0-9, space, '-', ''' and '&'; first character a letter; 1-32 characters after canonicalization; at most 3 digits in total (not per run); no 'http' or 'www' in any case. Never make these rules stricter without a migration: stored blobs are re-checked on every decrypt.",
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"name": "plain brand",
|
|
6
|
+
"input": "VetPass",
|
|
7
|
+
"expected": "VetPass"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"name": "LearnCard",
|
|
11
|
+
"input": "LearnCard",
|
|
12
|
+
"expected": "LearnCard"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "ScoutPass",
|
|
16
|
+
"input": "ScoutPass",
|
|
17
|
+
"expected": "ScoutPass"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "trims and collapses ASCII spaces",
|
|
21
|
+
"input": " Learn Card ",
|
|
22
|
+
"expected": "Learn Card"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "allowed punctuation",
|
|
26
|
+
"input": "Learn-Card's & Co",
|
|
27
|
+
"expected": "Learn-Card's & Co"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "three digits allowed",
|
|
31
|
+
"input": "Pass 123",
|
|
32
|
+
"expected": "Pass 123"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "exactly 32 characters",
|
|
36
|
+
"input": "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
37
|
+
"expected": "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "32 after collapsing",
|
|
41
|
+
"input": " Aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbb ",
|
|
42
|
+
"expected": "Aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbb"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "33 characters",
|
|
46
|
+
"input": "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
47
|
+
"expected": null
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "empty",
|
|
51
|
+
"input": "",
|
|
52
|
+
"expected": null
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "only spaces",
|
|
56
|
+
"input": " ",
|
|
57
|
+
"expected": null
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "leading digit",
|
|
61
|
+
"input": "1Pass",
|
|
62
|
+
"expected": null
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "leading hyphen",
|
|
66
|
+
"input": "-Pass",
|
|
67
|
+
"expected": null
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "four-digit run",
|
|
71
|
+
"input": "a1234",
|
|
72
|
+
"expected": null
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "code split by spaces",
|
|
76
|
+
"input": "Code 123 456 78",
|
|
77
|
+
"expected": null
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "phone split by hyphens",
|
|
81
|
+
"input": "A 555-123-456",
|
|
82
|
+
"expected": null
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "pin split into pairs",
|
|
86
|
+
"input": "Pin 12-34-56-78",
|
|
87
|
+
"expected": null
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "four digits apart",
|
|
91
|
+
"input": "A1 B2 C3 D4",
|
|
92
|
+
"expected": null
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"name": "three digits apart",
|
|
96
|
+
"input": "A1 B2 C3",
|
|
97
|
+
"expected": "A1 B2 C3"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "phone look-alike",
|
|
101
|
+
"input": "Call 5551234",
|
|
102
|
+
"expected": null
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "http substring",
|
|
106
|
+
"input": "http",
|
|
107
|
+
"expected": null
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "HTTP any case",
|
|
111
|
+
"input": "MyHttpBrand",
|
|
112
|
+
"expected": null
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "www substring",
|
|
116
|
+
"input": "Www Brand",
|
|
117
|
+
"expected": null
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"name": "carriage return",
|
|
121
|
+
"input": "Vet\rPass",
|
|
122
|
+
"expected": null
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "line feed",
|
|
126
|
+
"input": "Vet\nPass",
|
|
127
|
+
"expected": null
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"name": "CRLF header injection",
|
|
131
|
+
"input": "VetPass\r\nBcc: x",
|
|
132
|
+
"expected": null
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"name": "tab",
|
|
136
|
+
"input": "Vet\tPass",
|
|
137
|
+
"expected": null
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"name": "leading tab is not trimmed",
|
|
141
|
+
"input": "\tVetPass",
|
|
142
|
+
"expected": null
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"name": "NBSP",
|
|
146
|
+
"input": "Vet Pass",
|
|
147
|
+
"expected": null
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"name": "line separator",
|
|
151
|
+
"input": "Vet
Pass",
|
|
152
|
+
"expected": null
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"name": "RLO bidi override",
|
|
156
|
+
"input": "VetPass",
|
|
157
|
+
"expected": null
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"name": "LRI bidi isolate",
|
|
161
|
+
"input": "VetPass",
|
|
162
|
+
"expected": null
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"name": "zero-width space",
|
|
166
|
+
"input": "VetPass",
|
|
167
|
+
"expected": null
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"name": "Cyrillic homoglyph",
|
|
171
|
+
"input": "VеtPass",
|
|
172
|
+
"expected": null
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"name": "accented letter",
|
|
176
|
+
"input": "Café",
|
|
177
|
+
"expected": null
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"name": "less-than",
|
|
181
|
+
"input": "Vet<Pass",
|
|
182
|
+
"expected": null
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"name": "double quote",
|
|
186
|
+
"input": "Vet\"Pass",
|
|
187
|
+
"expected": null
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"name": "backslash",
|
|
191
|
+
"input": "Vet\\Pass",
|
|
192
|
+
"expected": null
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"name": "at sign",
|
|
196
|
+
"input": "Vet@Pass",
|
|
197
|
+
"expected": null
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"name": "dot",
|
|
201
|
+
"input": "vetpass.com",
|
|
202
|
+
"expected": null
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"name": "slash",
|
|
206
|
+
"input": "Vet/Pass",
|
|
207
|
+
"expected": null
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"name": "colon",
|
|
211
|
+
"input": "Vet:Pass",
|
|
212
|
+
"expected": null
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"name": "percent",
|
|
216
|
+
"input": "Vet%Pass",
|
|
217
|
+
"expected": null
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"name": "NUL",
|
|
221
|
+
"input": "Vet\u0000Pass",
|
|
222
|
+
"expected": null
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
}
|
|
@@ -288,6 +288,7 @@ describe('violatesCarryContactRule (P2-d cross-language contract)', () => {
|
|
|
288
288
|
action: 'enroll' | 'replace' | 'disable' | 'carry';
|
|
289
289
|
blobRecoveryEmail: string | null;
|
|
290
290
|
blobRecoveryLanguage: string | null;
|
|
291
|
+
blobRecoveryBrand?: string | null;
|
|
291
292
|
expected: 'accept' | 'reject';
|
|
292
293
|
}[]
|
|
293
294
|
).map(c => [c.name, c] as const)
|
|
@@ -295,6 +296,7 @@ describe('violatesCarryContactRule (P2-d cross-language contract)', () => {
|
|
|
295
296
|
const violates = violatesCarryContactRule(testCase.action, {
|
|
296
297
|
recoveryEmail: testCase.blobRecoveryEmail ?? undefined,
|
|
297
298
|
recoveryLanguage: testCase.blobRecoveryLanguage ?? undefined,
|
|
299
|
+
recoveryBrand: testCase.blobRecoveryBrand ?? undefined,
|
|
298
300
|
});
|
|
299
301
|
expect(violates).toBe(testCase.expected === 'reject');
|
|
300
302
|
});
|
package/src/escrow-authority.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { base64ToBuffer, bufferToBase64, bytesToHex } from './crypto';
|
|
2
2
|
import { parseEscrowEnvelope } from './escrow-crypto';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ESCROW_AUTHORITY_TENANT_PATTERN,
|
|
5
|
+
EscrowAuthorityTenantMismatchError,
|
|
6
|
+
EscrowEvidenceError,
|
|
7
|
+
} from './escrow-authority.types';
|
|
4
8
|
import type { HistoricalEscrowEvidence } from './escrow-authority.types';
|
|
5
9
|
import type { EscrowAttestationPolicy } from './types';
|
|
6
10
|
import {
|
|
@@ -100,6 +104,14 @@ export const verifyEscrowCodeDelivery = async (
|
|
|
100
104
|
const requireEvidence = (condition: boolean): void => {
|
|
101
105
|
if (!condition) fail();
|
|
102
106
|
};
|
|
107
|
+
/** Only after the signature verified: a well-formed but different signed tenant is a
|
|
108
|
+
* deployment misconfiguration (wrong `escrowAuthorityTenant`), reported specifically. */
|
|
109
|
+
const requireAuthorityTenant = (signedTenant: unknown, expectedTenant: string): void => {
|
|
110
|
+
if (signedTenant === expectedTenant) return;
|
|
111
|
+
if (typeof signedTenant === 'string' && ESCROW_AUTHORITY_TENANT_PATTERN.test(signedTenant))
|
|
112
|
+
throw new EscrowAuthorityTenantMismatchError(expectedTenant, signedTenant);
|
|
113
|
+
fail();
|
|
114
|
+
};
|
|
103
115
|
const object = (value: unknown): Record<string, unknown> => {
|
|
104
116
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return fail();
|
|
105
117
|
return value as Record<string, unknown>;
|
|
@@ -335,7 +347,8 @@ export const verifyEscrowAuthorityState = async (
|
|
|
335
347
|
const { payload } = await verifyRecord(raw.proof, 'snapshot', identity);
|
|
336
348
|
if (!Array.isArray(payload) || payload.length !== 2 || payload[0] !== nonce) return fail();
|
|
337
349
|
const signed = state(payload[1]);
|
|
338
|
-
requireEvidence(signed.
|
|
350
|
+
requireEvidence(signed.did === did);
|
|
351
|
+
requireAuthorityTenant(signed.tenant, tenant);
|
|
339
352
|
requireEvidence(JSON.stringify(signed) === JSON.stringify(state(raw.state)));
|
|
340
353
|
if (signed.envelope) {
|
|
341
354
|
requireEvidence(
|
|
@@ -497,7 +510,8 @@ export const verifyEscrowHoldRecord = async (
|
|
|
497
510
|
keys(commit, ['schemaVersion', 'binding', 'executionId', 'revisions', 'transactionDigest']);
|
|
498
511
|
const binding = object(commit.binding);
|
|
499
512
|
keys(binding, ['tenant', 'generation', 'didHash']);
|
|
500
|
-
requireEvidence(commit.schemaVersion === 1
|
|
513
|
+
requireEvidence(commit.schemaVersion === 1);
|
|
514
|
+
requireAuthorityTenant(binding.tenant, expected.tenant);
|
|
501
515
|
const authorityGeneration = text(binding.generation, 128);
|
|
502
516
|
if (historical) requireEvidence(authorityGeneration === historical.authorityGeneration);
|
|
503
517
|
requireEvidence(bytesToHex(bytes(binding.didHash, 32)) === (await hash(utf8(expected.did))));
|
|
@@ -608,16 +622,19 @@ export const resolveEscrowEnrollmentAction = (
|
|
|
608
622
|
/** P2-d cross-language contract: mirrors the enclave's Carry contact-field
|
|
609
623
|
* rejection rule (`apply_enrollment_intent` in authority.rs) — true means
|
|
610
624
|
* the enclave would refuse this target blob. The enclave always fills
|
|
611
|
-
* recoveryEmail/recoveryLanguage
|
|
612
|
-
* target that already claims
|
|
625
|
+
* recoveryEmail/recoveryLanguage (and, D21, the sealed recoveryBrand) in from
|
|
626
|
+
* the source blob on carry, so a target that already claims any of them is
|
|
627
|
+
* refused. Shared by
|
|
613
628
|
* `escrow-carry-contact-contract-v1.json`'s TS and Rust tests, and by any
|
|
614
629
|
* caller (e.g. a test fake) that wants to fail the same way the real
|
|
615
630
|
* enclave does instead of accepting a shape it would reject. */
|
|
616
631
|
export const violatesCarryContactRule = (
|
|
617
632
|
action: EscrowEnrollmentIntent['action'],
|
|
618
|
-
target: Pick<EscrowBlobPlaintext, 'recoveryEmail' | 'recoveryLanguage'> | null
|
|
633
|
+
target: Pick<EscrowBlobPlaintext, 'recoveryEmail' | 'recoveryLanguage' | 'recoveryBrand'> | null
|
|
619
634
|
): boolean =>
|
|
620
|
-
action === 'carry' &&
|
|
635
|
+
action === 'carry' &&
|
|
636
|
+
!!target &&
|
|
637
|
+
(!!target.recoveryEmail || !!target.recoveryLanguage || !!target.recoveryBrand);
|
|
621
638
|
|
|
622
639
|
/** Owner-only mutation selection. Recovery itself never calls this helper.
|
|
623
640
|
* P9.1 (attack C): `recoveryEmail`/`recoveryLanguage` are always supplied by the
|
|
@@ -8,6 +8,33 @@ export class EscrowEvidenceError extends Error {
|
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Shape of an escrow authority tenant label (mirrors the enclave's measured label check). */
|
|
12
|
+
export const ESCROW_AUTHORITY_TENANT_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validly signed evidence bound to a different authority tenant than this client expects.
|
|
16
|
+
* The enclave is single-tenant (its measured `ESCROW_AUTHORITY_TENANT` label), so this is
|
|
17
|
+
* a deployment misconfiguration, not a forgery: the app's `escrowAuthorityTenant` does not
|
|
18
|
+
* match the enclave it talks to. Still an {@link EscrowEvidenceError}, so every existing
|
|
19
|
+
* fail-closed path treats it the same way; only the message is specific. Labels are public
|
|
20
|
+
* deployment identifiers, never user data.
|
|
21
|
+
*/
|
|
22
|
+
export class EscrowAuthorityTenantMismatchError extends EscrowEvidenceError {
|
|
23
|
+
readonly expectedTenant: string;
|
|
24
|
+
readonly signedTenant: string;
|
|
25
|
+
|
|
26
|
+
constructor(expectedTenant: string, signedTenant: string) {
|
|
27
|
+
super();
|
|
28
|
+
this.name = 'EscrowAuthorityTenantMismatchError';
|
|
29
|
+
this.expectedTenant = expectedTenant;
|
|
30
|
+
this.signedTenant = signedTenant;
|
|
31
|
+
this.message =
|
|
32
|
+
`Escrow authority tenant mismatch: the enclave signed for "${signedTenant}" but this ` +
|
|
33
|
+
`app expects "${expectedTenant}". Set auth.sss.escrowAuthorityTenant to the ` +
|
|
34
|
+
`enclave's ESCROW_AUTHORITY_TENANT.`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
/** Opt-in for previously issued hold evidence only. Measurements are SHA-256
|
|
12
39
|
* hashes of complete configured PCR0/1/2 tuples, never server-supplied values.
|
|
13
40
|
* The original authority generation and current attested ledger key remain pinned.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ESCROW_CLIENT_KEY_ID,
|
|
8
8
|
ESCROW_ENVELOPE_VERSION,
|
|
9
9
|
ESCROW_RELEASE_INFO,
|
|
10
|
+
canonicalEscrowEmailBrand,
|
|
10
11
|
decryptEscrowBlob,
|
|
11
12
|
encryptEscrowBlob,
|
|
12
13
|
generateEscrowKeyPair,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
sealEscrowRelease,
|
|
18
19
|
} from './escrow-crypto';
|
|
19
20
|
import type { EscrowEnvelope } from './escrow-crypto';
|
|
21
|
+
import brandContract from './__fixtures__/escrow-email-brand-contract-v1.json';
|
|
20
22
|
|
|
21
23
|
const blob = {
|
|
22
24
|
recoveryShare: `0001${'ab'.repeat(48)}`,
|
|
@@ -379,3 +381,76 @@ describe('escrow parsing', () => {
|
|
|
379
381
|
expect(parseEscrowReleasePlaintext(maximum)).toEqual(maximum);
|
|
380
382
|
});
|
|
381
383
|
});
|
|
384
|
+
|
|
385
|
+
// D21 cross-language contract: the enclave's `canonical_brand` (crypto.rs)
|
|
386
|
+
// loads the same fixture and must reach the identical result for each case.
|
|
387
|
+
describe('escrow email brand (D21)', () => {
|
|
388
|
+
const cases = brandContract.cases as { name: string; input: string; expected: string | null }[];
|
|
389
|
+
const contact = { recoveryEmail: 'owner@example.com', recoveryLanguage: 'en' };
|
|
390
|
+
|
|
391
|
+
it.each(cases.map(c => [c.name, c] as const))('canonicalizes %s', (_name, testCase): void => {
|
|
392
|
+
expect(canonicalEscrowEmailBrand(testCase.input)).toBe(testCase.expected ?? undefined);
|
|
393
|
+
if (testCase.expected !== null) {
|
|
394
|
+
expect(canonicalEscrowEmailBrand(testCase.expected)).toBe(testCase.expected);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it.each(cases.map(c => [c.name, c] as const))(
|
|
399
|
+
'blob and release parsers keep the canonical brand or omit it, never throw: %s',
|
|
400
|
+
(_name, testCase): void => {
|
|
401
|
+
const parsed = parseEscrowBlobPlaintext({
|
|
402
|
+
...plaintext,
|
|
403
|
+
...contact,
|
|
404
|
+
recoveryBrand: testCase.input,
|
|
405
|
+
});
|
|
406
|
+
const released = parseEscrowReleasePlaintext({
|
|
407
|
+
...releasePlaintext,
|
|
408
|
+
...contact,
|
|
409
|
+
recoveryBrand: testCase.input,
|
|
410
|
+
});
|
|
411
|
+
if (testCase.expected === null) {
|
|
412
|
+
expect(parsed).not.toHaveProperty('recoveryBrand');
|
|
413
|
+
expect(released).not.toHaveProperty('recoveryBrand');
|
|
414
|
+
} else {
|
|
415
|
+
expect(parsed.recoveryBrand).toBe(testCase.expected);
|
|
416
|
+
expect(released.recoveryBrand).toBe(testCase.expected);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
it.each([null, 7, ['VetPass'], { name: 'VetPass' }, true])(
|
|
422
|
+
'drops a mistyped brand %j instead of rejecting the plaintext',
|
|
423
|
+
(recoveryBrand): void => {
|
|
424
|
+
expect(parseEscrowBlobPlaintext({ ...plaintext, ...contact, recoveryBrand })).toEqual({
|
|
425
|
+
...plaintext,
|
|
426
|
+
...contact,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
it('keeps the brand through encryptEscrowBlob/decryptEscrowBlob and sealing (regression guard: the parser used to drop unknown fields)', async (): Promise<void> => {
|
|
432
|
+
const keys = await generateEscrowKeyPair();
|
|
433
|
+
const envelope = await encryptEscrowBlob(
|
|
434
|
+
{ ...blob, ...contact, recoveryBrand: ' Vet Pass ' },
|
|
435
|
+
keys.publicKey,
|
|
436
|
+
'enclave-1'
|
|
437
|
+
);
|
|
438
|
+
expect(await decryptEscrowBlob(envelope, keys.privateKey)).toEqual({
|
|
439
|
+
...plaintext,
|
|
440
|
+
...contact,
|
|
441
|
+
recoveryBrand: 'Vet Pass',
|
|
442
|
+
});
|
|
443
|
+
const sealed = await sealEscrowRelease(
|
|
444
|
+
{ ...release, ...contact, recoveryBrand: 'VetPass' },
|
|
445
|
+
keys.publicKey
|
|
446
|
+
);
|
|
447
|
+
expect((await openEscrowRelease(sealed, keys.privateKey)).recoveryBrand).toBe('VetPass');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('keeps a brand with no contact (inert), matching the enclave', (): void => {
|
|
451
|
+
expect(parseEscrowBlobPlaintext({ ...plaintext, recoveryBrand: 'VetPass' })).toEqual({
|
|
452
|
+
...plaintext,
|
|
453
|
+
recoveryBrand: 'VetPass',
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|
package/src/escrow-crypto.ts
CHANGED
|
@@ -35,7 +35,11 @@ export const RECOVERY_LANGUAGES = ['en', 'es', 'fr', 'ar'] as const;
|
|
|
35
35
|
/** Recovery share bound to an identity and a positive share version.
|
|
36
36
|
* `recoveryEmail`/`recoveryLanguage`: bound by the user's own device at
|
|
37
37
|
* enrollment, never by lca-api. Always both-present or both-absent; changing
|
|
38
|
-
* the address requires a fresh `replace` enrollment, not an edit.
|
|
38
|
+
* the address requires a fresh `replace` enrollment, not an edit.
|
|
39
|
+
* `recoveryBrand` (D21): display name for enclave-sent recovery emails, sealed
|
|
40
|
+
* by the owner's device at enrollment alongside the contact. Not in the signed
|
|
41
|
+
* intent; canonical per `canonicalEscrowEmailBrand`, and silently dropped
|
|
42
|
+
* (never an error) when invalid so recovery is never refused over display text. */
|
|
39
43
|
export interface EscrowBlobPlaintext {
|
|
40
44
|
version: typeof ESCROW_ENVELOPE_VERSION;
|
|
41
45
|
recoveryShare: string;
|
|
@@ -44,6 +48,7 @@ export interface EscrowBlobPlaintext {
|
|
|
44
48
|
pinVerifier?: string;
|
|
45
49
|
recoveryEmail?: string;
|
|
46
50
|
recoveryLanguage?: string;
|
|
51
|
+
recoveryBrand?: string;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/** Released recovery share additionally bound to its recovery hold. */
|
|
@@ -51,6 +56,38 @@ export interface EscrowReleasePlaintext extends EscrowBlobPlaintext {
|
|
|
51
56
|
holdId: string;
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/** D21: longest canonical email brand, in characters (ASCII only, so also bytes). */
|
|
60
|
+
export const MAX_ESCROW_EMAIL_BRAND_LENGTH = 32;
|
|
61
|
+
|
|
62
|
+
/** D21: most digits an escrow email brand may contain, in total (not per run). */
|
|
63
|
+
export const MAX_ESCROW_EMAIL_BRAND_DIGITS = 3;
|
|
64
|
+
|
|
65
|
+
/** D21: canonical form of an owner-sealed escrow email brand, or `undefined` if
|
|
66
|
+
* it is not acceptable display text. Mirrors the enclave's `canonical_brand`
|
|
67
|
+
* (crypto.rs) exactly; both are pinned by escrow-email-brand-contract-v1.json.
|
|
68
|
+
* Trims and collapses runs of ASCII space only (every other whitespace or
|
|
69
|
+
* control character rejects); allows only `A-Z a-z 0-9`, space, `-`, `'`, `&`;
|
|
70
|
+
* must start with a letter, be 1-32 characters, contain at most 3 digits in total
|
|
71
|
+
* (not per run, so `Code 123 456 78` rejects) and no `http`/`www` in any case. ASCII-only by design: no URL, address, bidi or
|
|
72
|
+
* homoglyph can be formed, and no RFC 2047 encoding is ever needed. */
|
|
73
|
+
export const canonicalEscrowEmailBrand = (raw: unknown): string | undefined => {
|
|
74
|
+
if (typeof raw !== 'string') return undefined;
|
|
75
|
+
const brand = raw
|
|
76
|
+
.split(' ')
|
|
77
|
+
.filter(word => word.length > 0)
|
|
78
|
+
.join(' ');
|
|
79
|
+
if (
|
|
80
|
+
brand.length === 0 ||
|
|
81
|
+
brand.length > MAX_ESCROW_EMAIL_BRAND_LENGTH ||
|
|
82
|
+
!/^[A-Za-z][A-Za-z0-9 '&-]*$/.test(brand) ||
|
|
83
|
+
(brand.match(/[0-9]/g)?.length ?? 0) > MAX_ESCROW_EMAIL_BRAND_DIGITS ||
|
|
84
|
+
/http|www/i.test(brand)
|
|
85
|
+
) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
return brand;
|
|
89
|
+
};
|
|
90
|
+
|
|
54
91
|
const encodeUtf8 = (value: string): Uint8Array<ArrayBuffer> => {
|
|
55
92
|
const bytes = new TextEncoder().encode(value);
|
|
56
93
|
const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength));
|
|
@@ -142,6 +179,10 @@ export const parseEscrowBlobPlaintext = (value: unknown): EscrowBlobPlaintext =>
|
|
|
142
179
|
}
|
|
143
180
|
recoveryContact = { recoveryEmail: email, recoveryLanguage: language };
|
|
144
181
|
}
|
|
182
|
+
// D21: lenient on read, exactly like the enclave's `normalize`: an invalid
|
|
183
|
+
// or mistyped brand is omitted, never an error. Independent of the contact
|
|
184
|
+
// (kept but inert without one).
|
|
185
|
+
const recoveryBrand = canonicalEscrowEmailBrand(value.recoveryBrand);
|
|
145
186
|
return {
|
|
146
187
|
version: ESCROW_ENVELOPE_VERSION,
|
|
147
188
|
recoveryShare: requireString(value, 'recoveryShare', {
|
|
@@ -161,6 +202,7 @@ export const parseEscrowBlobPlaintext = (value: unknown): EscrowBlobPlaintext =>
|
|
|
161
202
|
}
|
|
162
203
|
: {}),
|
|
163
204
|
...recoveryContact,
|
|
205
|
+
...(recoveryBrand !== undefined ? { recoveryBrand } : {}),
|
|
164
206
|
};
|
|
165
207
|
};
|
|
166
208
|
|