@oxy.so/core 1.5.0 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +72 -9
- package/dist/cjs/crypto/identityMove.js +53 -34
- package/dist/cjs/crypto/identityProof.js +28 -0
- package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
- package/dist/cjs/i18n/locales/ar-SA.json +1 -0
- package/dist/cjs/i18n/locales/ca-ES.json +1 -0
- package/dist/cjs/i18n/locales/de-DE.json +1 -0
- package/dist/cjs/i18n/locales/en-US.json +1 -0
- package/dist/cjs/i18n/locales/es-ES.json +1 -0
- package/dist/cjs/i18n/locales/fr-FR.json +1 -0
- package/dist/cjs/i18n/locales/it-IT.json +1 -0
- package/dist/cjs/i18n/locales/ja-JP.json +1 -0
- package/dist/cjs/i18n/locales/ko-KR.json +1 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
- package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
- package/dist/cjs/i18n/locales/pt-PT.json +1 -0
- package/dist/cjs/i18n/locales/zh-CN.json +1 -0
- package/dist/cjs/index.js +22 -14
- package/dist/cjs/inference/OxyInferenceClient.js +62 -5
- package/dist/cjs/mixins/OxyServices.auth.js +129 -0
- package/dist/cjs/mixins/OxyServices.identity.js +11 -44
- package/dist/cjs/mixins/index.js +0 -2
- package/dist/cjs/server/index.js +10 -1
- package/dist/cjs/server/securityHeaders.js +9 -2
- package/dist/cjs/server/workloadIdentity.js +170 -0
- package/dist/cjs/session/refresh.js +12 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +72 -9
- package/dist/esm/crypto/identityMove.js +51 -33
- package/dist/esm/crypto/identityProof.js +24 -0
- package/dist/esm/crypto/webIdentityCarrier.js +137 -77
- package/dist/esm/i18n/locales/ar-SA.json +1 -0
- package/dist/esm/i18n/locales/ca-ES.json +1 -0
- package/dist/esm/i18n/locales/de-DE.json +1 -0
- package/dist/esm/i18n/locales/en-US.json +1 -0
- package/dist/esm/i18n/locales/es-ES.json +1 -0
- package/dist/esm/i18n/locales/fr-FR.json +1 -0
- package/dist/esm/i18n/locales/it-IT.json +1 -0
- package/dist/esm/i18n/locales/ja-JP.json +1 -0
- package/dist/esm/i18n/locales/ko-KR.json +1 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
- package/dist/esm/i18n/locales/locales/en-US.json +1 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
- package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
- package/dist/esm/i18n/locales/pt-PT.json +1 -0
- package/dist/esm/i18n/locales/zh-CN.json +1 -0
- package/dist/esm/index.js +5 -4
- package/dist/esm/inference/OxyInferenceClient.js +63 -6
- package/dist/esm/mixins/OxyServices.auth.js +96 -0
- package/dist/esm/mixins/OxyServices.identity.js +11 -44
- package/dist/esm/mixins/index.js +0 -2
- package/dist/esm/server/index.js +7 -0
- package/dist/esm/server/securityHeaders.js +9 -2
- package/dist/esm/server/workloadIdentity.js +166 -0
- package/dist/esm/session/refresh.js +12 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +24 -0
- package/dist/types/OxyServices.d.ts +3 -0
- package/dist/types/crypto/identityMove.d.ts +48 -32
- package/dist/types/crypto/identityProof.d.ts +16 -0
- package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
- package/dist/types/index.d.ts +7 -6
- package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
- package/dist/types/mixins/OxyServices.utility.d.ts +2 -0
- package/dist/types/mixins/index.d.ts +1 -2
- package/dist/types/server/index.d.ts +8 -0
- package/dist/types/server/securityHeaders.d.ts +8 -0
- package/dist/types/server/workloadIdentity.d.ts +32 -0
- package/package.json +1 -1
- package/src/HttpService.ts +74 -9
- package/src/OxyServices.ts +9 -0
- package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
- package/src/__tests__/publicInterfaceParity.test.ts +75 -0
- package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
- package/src/crypto/__tests__/identityMove.test.ts +93 -51
- package/src/crypto/__tests__/identityProof.test.ts +81 -0
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
- package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
- package/src/crypto/identityMove.ts +77 -43
- package/src/crypto/identityProof.ts +36 -0
- package/src/crypto/webIdentityCarrier.ts +185 -117
- package/src/i18n/locales/ar-SA.json +1 -0
- package/src/i18n/locales/ca-ES.json +1 -0
- package/src/i18n/locales/de-DE.json +1 -0
- package/src/i18n/locales/en-US.json +1 -0
- package/src/i18n/locales/es-ES.json +1 -0
- package/src/i18n/locales/fr-FR.json +1 -0
- package/src/i18n/locales/it-IT.json +1 -0
- package/src/i18n/locales/ja-JP.json +1 -0
- package/src/i18n/locales/ko-KR.json +1 -0
- package/src/i18n/locales/pt-PT.json +1 -0
- package/src/i18n/locales/zh-CN.json +1 -0
- package/src/index.ts +26 -15
- package/src/inference/OxyInferenceClient.ts +84 -7
- package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
- package/src/mixins/OxyServices.auth.ts +100 -1
- package/src/mixins/OxyServices.identity.ts +17 -67
- package/src/mixins/OxyServices.utility.ts +2 -0
- package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
- package/src/mixins/index.ts +0 -3
- package/src/server/__tests__/securityHeaders.test.ts +15 -0
- package/src/server/__tests__/workloadIdentity.test.ts +128 -0
- package/src/server/index.ts +9 -0
- package/src/server/securityHeaders.ts +19 -6
- package/src/server/workloadIdentity.ts +207 -0
- package/src/session/__tests__/refresh.test.ts +53 -3
- package/src/session/refresh.ts +12 -0
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
- package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
|
@@ -1,96 +1,138 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* receives exactly the identity
|
|
4
|
-
*
|
|
2
|
+
* Giving a root to Commons (ADR 0024 D6): both sides derive the same key and code;
|
|
3
|
+
* the responder receives exactly the declared identity; the initiator's key is
|
|
4
|
+
* committed before the responder's is chosen, so a relay cannot steer both codes
|
|
5
|
+
* together; and the receipt binds the move, root, both keys and the relayed
|
|
6
|
+
* ciphertext.
|
|
5
7
|
*/
|
|
6
8
|
|
|
9
|
+
import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
|
|
10
|
+
import { signMessage } from '@oxy.so/protocol';
|
|
7
11
|
import {
|
|
8
12
|
buildMoveQrPayload,
|
|
13
|
+
createMoveCommitment,
|
|
9
14
|
deriveMoveKey,
|
|
10
15
|
deriveMoveSas,
|
|
16
|
+
digestMoveCiphertext,
|
|
11
17
|
generateMoveEphemeralKeyPair,
|
|
12
|
-
IDENTITY_MOVE_ACTIONS,
|
|
13
18
|
openMovedIdentity,
|
|
14
19
|
parseMoveQrPayload,
|
|
15
20
|
sealIdentityForMove,
|
|
16
|
-
|
|
21
|
+
signMoveReceipt,
|
|
22
|
+
verifyMoveCommitment,
|
|
17
23
|
verifyMoveReceipt,
|
|
18
24
|
} from '../identityMove';
|
|
19
|
-
import { generateWebIdentity } from '../webIdentityCarrier';
|
|
25
|
+
import { deriveIdentityFromMnemonic, generateWebIdentity } from '../webIdentityCarrier';
|
|
20
26
|
|
|
21
27
|
const MOVE_ID = '0123456789abcdef0123456789abcdef';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
28
|
+
const MNEMONIC_24 =
|
|
29
|
+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
|
|
30
|
+
|
|
31
|
+
function code(initiator: string, responder: string, commitment: string, moveId = MOVE_ID): string {
|
|
32
|
+
return deriveMoveSas({ moveId, initiatorEphemeralPublicKey: initiator, responderEphemeralPublicKey: responder, initiatorCommitment: commitment });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('a move between two honest sides', () => {
|
|
36
|
+
it('delivers the declared identity — 12 or 24 words — and both screens show the same code', () => {
|
|
37
|
+
for (const identity of [generateWebIdentity(), deriveIdentityFromMnemonic(MNEMONIC_24)]) {
|
|
38
|
+
const web = generateMoveEphemeralKeyPair();
|
|
39
|
+
const commons = generateMoveEphemeralKeyPair();
|
|
40
|
+
const { commitment, nonce } = createMoveCommitment(web.publicKey);
|
|
41
|
+
expect(verifyMoveCommitment(web.publicKey, nonce, commitment)).toBe(true);
|
|
42
|
+
|
|
43
|
+
const webKey = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
|
|
44
|
+
const commonsKey = deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID);
|
|
45
|
+
expect(Buffer.from(webKey).equals(Buffer.from(commonsKey))).toBe(true);
|
|
46
|
+
expect(code(web.publicKey, commons.publicKey, commitment)).toMatch(/^\d{6}$/);
|
|
47
|
+
expect(code(web.publicKey, commons.publicKey, commitment, MOVE_ID.toUpperCase())).toBe(code(web.publicKey, commons.publicKey, commitment));
|
|
48
|
+
|
|
49
|
+
const sealed = sealIdentityForMove(identity, webKey, MOVE_ID);
|
|
50
|
+
expect(JSON.stringify(sealed)).not.toContain(identity.privateKey);
|
|
51
|
+
expect(openMovedIdentity(sealed, commonsKey, MOVE_ID, identity.publicKey)).toEqual(identity);
|
|
52
|
+
}
|
|
40
53
|
});
|
|
41
54
|
});
|
|
42
55
|
|
|
43
|
-
describe('a relay that substitutes
|
|
56
|
+
describe('a relay that substitutes keys', () => {
|
|
57
|
+
it('cannot reveal a key the commitment does not open', () => {
|
|
58
|
+
const web = generateSecp256k1KeyPair();
|
|
59
|
+
const { commitment, nonce } = createMoveCommitment(web.publicKey);
|
|
60
|
+
expect(verifyMoveCommitment(generateSecp256k1KeyPair().publicKey, nonce, commitment)).toBe(false);
|
|
61
|
+
expect(verifyMoveCommitment(web.publicKey, 'ab'.repeat(32), commitment)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
44
64
|
it('shows a different code on each screen and cannot open the identity', () => {
|
|
45
65
|
const identity = generateWebIdentity();
|
|
46
66
|
const web = generateMoveEphemeralKeyPair();
|
|
47
67
|
const commons = generateMoveEphemeralKeyPair();
|
|
48
68
|
const attacker = generateMoveEphemeralKeyPair();
|
|
69
|
+
const { commitment } = createMoveCommitment(web.publicKey);
|
|
70
|
+
const attackerCommitment = createMoveCommitment(attacker.publicKey).commitment;
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
const webSas = deriveMoveSas(MOVE_ID, web.publicKey, attacker.publicKey);
|
|
52
|
-
const commonsSas = deriveMoveSas(MOVE_ID, attacker.publicKey, commons.publicKey);
|
|
53
|
-
expect(webSas).not.toBe(commonsSas);
|
|
72
|
+
expect(code(web.publicKey, attacker.publicKey, commitment)).not.toBe(code(attacker.publicKey, commons.publicKey, attackerCommitment));
|
|
54
73
|
|
|
55
|
-
// Even sealed to the attacker, Commons' own key cannot open it.
|
|
56
74
|
const sealedToAttacker = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, attacker.publicKey, MOVE_ID), MOVE_ID);
|
|
57
75
|
expect(() =>
|
|
58
76
|
openMovedIdentity(sealedToAttacker, deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID), MOVE_ID, identity.publicKey),
|
|
59
77
|
).toThrow('could not be opened');
|
|
60
78
|
});
|
|
61
79
|
|
|
62
|
-
it('
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
expect(() => openMovedIdentity(sealedOther, key, MOVE_ID, declared.publicKey)).toThrow();
|
|
80
|
+
it('changes the code when any input changes, including the commitment', () => {
|
|
81
|
+
const web = generateSecp256k1KeyPair();
|
|
82
|
+
const commons = generateSecp256k1KeyPair();
|
|
83
|
+
const { commitment } = createMoveCommitment(web.publicKey);
|
|
84
|
+
const base = code(web.publicKey, commons.publicKey, commitment);
|
|
85
|
+
expect(code(web.publicKey, commons.publicKey, createMoveCommitment(web.publicKey).commitment)).not.toBe(base);
|
|
86
|
+
expect(code(web.publicKey, generateSecp256k1KeyPair().publicKey, commitment)).not.toBe(base);
|
|
87
|
+
expect(code(commons.publicKey, web.publicKey, commitment)).not.toBe(base);
|
|
71
88
|
});
|
|
72
89
|
|
|
73
|
-
it('
|
|
74
|
-
const
|
|
90
|
+
it('refuses a sealed identity that is not the one the move declared, or another move’s ciphertext', () => {
|
|
91
|
+
const declared = generateWebIdentity();
|
|
75
92
|
const web = generateMoveEphemeralKeyPair();
|
|
76
93
|
const commons = generateMoveEphemeralKeyPair();
|
|
77
94
|
const key = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
|
|
78
|
-
|
|
79
|
-
expect(() => openMovedIdentity(
|
|
95
|
+
expect(() => openMovedIdentity(sealIdentityForMove(generateWebIdentity(), key, MOVE_ID), key, MOVE_ID, declared.publicKey)).toThrow();
|
|
96
|
+
expect(() => openMovedIdentity(sealIdentityForMove(declared, key, MOVE_ID), key, 'ffffffffffffffffffffffffffffffff', declared.publicKey)).toThrow();
|
|
80
97
|
});
|
|
81
98
|
});
|
|
82
99
|
|
|
83
100
|
describe('receipts', () => {
|
|
84
|
-
|
|
101
|
+
function transfer() {
|
|
85
102
|
const identity = generateWebIdentity();
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
const web = generateMoveEphemeralKeyPair();
|
|
104
|
+
const commons = generateMoveEphemeralKeyPair();
|
|
105
|
+
const sealed = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID), MOVE_ID);
|
|
106
|
+
const claims = {
|
|
107
|
+
moveId: MOVE_ID,
|
|
108
|
+
rootPublicKey: identity.publicKey,
|
|
109
|
+
initiatorEphemeralPublicKey: web.publicKey,
|
|
110
|
+
responderEphemeralPublicKey: commons.publicKey,
|
|
111
|
+
ciphertextDigest: digestMoveCiphertext(sealed),
|
|
112
|
+
};
|
|
113
|
+
return { identity, sealed, claims };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it('verify for exactly the move, root, keys and ciphertext they were made over', async () => {
|
|
117
|
+
const { identity, sealed, claims } = transfer();
|
|
118
|
+
const receipt = await signMoveReceipt((message) => signMessage(message, identity.privateKey), claims);
|
|
119
|
+
expect(await verifyMoveReceipt(claims, receipt.signature)).toBe(true);
|
|
120
|
+
|
|
121
|
+
const other = { ...sealed, ciphertext: `${sealed.ciphertext.slice(0, -2)}00` };
|
|
122
|
+
for (const changed of [
|
|
123
|
+
{ ...claims, moveId: 'ffffffffffffffffffffffffffffffff' },
|
|
124
|
+
{ ...claims, responderEphemeralPublicKey: generateSecp256k1KeyPair().publicKey },
|
|
125
|
+
{ ...claims, initiatorEphemeralPublicKey: generateSecp256k1KeyPair().publicKey },
|
|
126
|
+
{ ...claims, ciphertextDigest: digestMoveCiphertext(other) },
|
|
127
|
+
]) {
|
|
128
|
+
expect(await verifyMoveReceipt(changed, receipt.signature)).toBe(false);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
it('are not the root’s if another key signed them', async () => {
|
|
133
|
+
const { claims } = transfer();
|
|
134
|
+
const forged = await signMoveReceipt((message) => signMessage(message, generateWebIdentity().privateKey), claims);
|
|
135
|
+
expect(await verifyMoveReceipt(claims, forged.signature)).toBe(false);
|
|
94
136
|
});
|
|
95
137
|
});
|
|
96
138
|
|
|
@@ -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({
|
|
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
|
|
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.
|
|
126
|
-
expect(failureOf(() => openWebIdentity({ ...envelope,
|
|
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.
|
|
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
|
+
});
|