@oxy.so/core 1.16.0 → 1.18.1
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 +30 -1
- package/dist/cjs/crypto/identityLink.js +18 -0
- package/dist/cjs/i18n/locales/ar-SA.json +78 -3
- package/dist/cjs/i18n/locales/ca-ES.json +78 -3
- package/dist/cjs/i18n/locales/de-DE.json +78 -3
- package/dist/cjs/i18n/locales/en-US.json +75 -28
- package/dist/cjs/i18n/locales/es-ES.json +75 -28
- package/dist/cjs/i18n/locales/fr-FR.json +78 -3
- package/dist/cjs/i18n/locales/it-IT.json +78 -3
- package/dist/cjs/i18n/locales/ja-JP.json +78 -3
- package/dist/cjs/i18n/locales/ko-KR.json +78 -3
- package/dist/cjs/i18n/locales/locales/ar-SA.json +78 -3
- package/dist/cjs/i18n/locales/locales/ca-ES.json +78 -3
- package/dist/cjs/i18n/locales/locales/de-DE.json +78 -3
- package/dist/cjs/i18n/locales/locales/en-US.json +75 -28
- package/dist/cjs/i18n/locales/locales/es-ES.json +75 -28
- package/dist/cjs/i18n/locales/locales/fr-FR.json +78 -3
- package/dist/cjs/i18n/locales/locales/it-IT.json +78 -3
- package/dist/cjs/i18n/locales/locales/ja-JP.json +78 -3
- package/dist/cjs/i18n/locales/locales/ko-KR.json +78 -3
- package/dist/cjs/i18n/locales/locales/pt-PT.json +78 -3
- package/dist/cjs/i18n/locales/locales/zh-CN.json +78 -3
- package/dist/cjs/i18n/locales/pt-PT.json +78 -3
- package/dist/cjs/i18n/locales/zh-CN.json +78 -3
- package/dist/cjs/index.js +9 -42
- package/dist/cjs/mixins/OxyServices.auth.js +110 -17
- package/dist/cjs/mixins/OxyServices.identity.js +128 -8
- package/dist/cjs/mixins/OxyServices.user.js +42 -7
- package/dist/cjs/utils/authWebUrl.js +4 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +30 -1
- package/dist/esm/crypto/identityLink.js +15 -0
- package/dist/esm/i18n/locales/ar-SA.json +78 -3
- package/dist/esm/i18n/locales/ca-ES.json +78 -3
- package/dist/esm/i18n/locales/de-DE.json +78 -3
- package/dist/esm/i18n/locales/en-US.json +75 -28
- package/dist/esm/i18n/locales/es-ES.json +75 -28
- package/dist/esm/i18n/locales/fr-FR.json +78 -3
- package/dist/esm/i18n/locales/it-IT.json +78 -3
- package/dist/esm/i18n/locales/ja-JP.json +78 -3
- package/dist/esm/i18n/locales/ko-KR.json +78 -3
- package/dist/esm/i18n/locales/locales/ar-SA.json +78 -3
- package/dist/esm/i18n/locales/locales/ca-ES.json +78 -3
- package/dist/esm/i18n/locales/locales/de-DE.json +78 -3
- package/dist/esm/i18n/locales/locales/en-US.json +75 -28
- package/dist/esm/i18n/locales/locales/es-ES.json +75 -28
- package/dist/esm/i18n/locales/locales/fr-FR.json +78 -3
- package/dist/esm/i18n/locales/locales/it-IT.json +78 -3
- package/dist/esm/i18n/locales/locales/ja-JP.json +78 -3
- package/dist/esm/i18n/locales/locales/ko-KR.json +78 -3
- package/dist/esm/i18n/locales/locales/pt-PT.json +78 -3
- package/dist/esm/i18n/locales/locales/zh-CN.json +78 -3
- package/dist/esm/i18n/locales/pt-PT.json +78 -3
- package/dist/esm/i18n/locales/zh-CN.json +78 -3
- package/dist/esm/index.js +4 -4
- package/dist/esm/mixins/OxyServices.auth.js +110 -17
- package/dist/esm/mixins/OxyServices.identity.js +128 -8
- package/dist/esm/mixins/OxyServices.user.js +42 -7
- package/dist/esm/utils/authWebUrl.js +4 -5
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +21 -0
- package/dist/types/crypto/identityLink.d.ts +8 -0
- package/dist/types/index.d.ts +2 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +68 -25
- package/dist/types/mixins/OxyServices.identity.d.ts +34 -34
- package/dist/types/mixins/OxyServices.user.d.ts +29 -6
- package/dist/types/models/interfaces.d.ts +67 -13
- package/dist/types/server/securityHeaders.d.ts +3 -3
- package/dist/types/utils/authWebUrl.d.ts +4 -5
- package/package.json +3 -3
- package/src/HttpService.ts +35 -1
- package/src/__tests__/serviceIdentityWhenAnonymous.test.ts +159 -0
- package/src/crypto/__tests__/identityLink.test.ts +18 -0
- package/src/crypto/__tests__/identityProof.test.ts +6 -6
- package/src/crypto/identityLink.ts +18 -0
- package/src/i18n/locales/ar-SA.json +78 -3
- package/src/i18n/locales/ca-ES.json +78 -3
- package/src/i18n/locales/de-DE.json +78 -3
- package/src/i18n/locales/en-US.json +75 -28
- package/src/i18n/locales/es-ES.json +75 -28
- package/src/i18n/locales/fr-FR.json +78 -3
- package/src/i18n/locales/it-IT.json +78 -3
- package/src/i18n/locales/ja-JP.json +78 -3
- package/src/i18n/locales/ko-KR.json +78 -3
- package/src/i18n/locales/pt-PT.json +78 -3
- package/src/i18n/locales/zh-CN.json +78 -3
- package/src/index.ts +6 -48
- package/src/mixins/OxyServices.auth.ts +127 -24
- package/src/mixins/OxyServices.identity.ts +126 -8
- package/src/mixins/OxyServices.user.ts +51 -10
- package/src/mixins/__tests__/identityLink.test.ts +89 -0
- package/src/mixins/__tests__/notifications.read.test.ts +82 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +1 -1
- package/src/mixins/__tests__/webauthnAuth.test.ts +75 -1
- package/src/models/interfaces.ts +67 -12
- package/src/server/securityHeaders.ts +3 -3
- package/src/utils/authWebUrl.ts +4 -5
- package/dist/cjs/crypto/identityMove.js +0 -142
- package/dist/cjs/crypto/webIdentityCarrier.js +0 -325
- package/dist/esm/crypto/identityMove.js +0 -128
- package/dist/esm/crypto/webIdentityCarrier.js +0 -303
- package/dist/types/crypto/identityMove.d.ts +0 -75
- package/dist/types/crypto/webIdentityCarrier.d.ts +0 -150
- package/src/crypto/__tests__/identityMove.test.ts +0 -146
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +0 -169
- package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +0 -117
- package/src/crypto/identityMove.ts +0 -167
- package/src/crypto/webIdentityCarrier.ts +0 -394
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Web identity carrier — the envelope must carry EXACTLY the Commons identity,
|
|
3
|
-
* open only with a registered passkey's PRF output, and refuse (with a reason a
|
|
4
|
-
* UI can act on) anything else.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { webIdentityEnvelopeSchema } from '@oxy.so/contracts';
|
|
8
|
-
import { RecoveryPhraseService } from '../recoveryPhrase';
|
|
9
|
-
import {
|
|
10
|
-
addWrap,
|
|
11
|
-
deriveIdentityFromMnemonic,
|
|
12
|
-
generateWebIdentity,
|
|
13
|
-
openWebIdentity,
|
|
14
|
-
removeWrap,
|
|
15
|
-
sealWebIdentity,
|
|
16
|
-
unlockWebIdentity,
|
|
17
|
-
unwrapDataKey,
|
|
18
|
-
WEB_IDENTITY_PRF_INPUT,
|
|
19
|
-
WebIdentityUnlockError,
|
|
20
|
-
wipeBytes,
|
|
21
|
-
} from '../webIdentityCarrier';
|
|
22
|
-
|
|
23
|
-
/** The BIP-39 reference mnemonic (all-zero entropy). */
|
|
24
|
-
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
25
|
-
|
|
26
|
-
const prf = (fill: number): Uint8Array => new Uint8Array(32).fill(fill);
|
|
27
|
-
const CREDENTIAL_A = 'credential-aaaaaaaaaaaaaaaa';
|
|
28
|
-
const CREDENTIAL_B = 'credential-bbbbbbbbbbbbbbbb';
|
|
29
|
-
|
|
30
|
-
const failureOf = (fn: () => unknown): string | undefined => {
|
|
31
|
-
try {
|
|
32
|
-
fn();
|
|
33
|
-
} catch (error) {
|
|
34
|
-
return error instanceof WebIdentityUnlockError ? error.failure : 'other';
|
|
35
|
-
}
|
|
36
|
-
return undefined;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
describe('the identity itself', () => {
|
|
40
|
-
it('derives the SAME key Commons derives from a phrase — one identity, not a second kind', async () => {
|
|
41
|
-
const identity = deriveIdentityFromMnemonic(MNEMONIC);
|
|
42
|
-
|
|
43
|
-
expect(identity.privateKey).toBe(await RecoveryPhraseService.derivePrivateKeyFromPhrase(MNEMONIC));
|
|
44
|
-
expect(identity.publicKey).toBe(await RecoveryPhraseService.derivePublicKeyFromPhrase(MNEMONIC));
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('normalizes case and spacing the way a person types a phrase', () => {
|
|
48
|
-
const typed = ` ${MNEMONIC.toUpperCase().split(' ').join(' ')} `;
|
|
49
|
-
expect(deriveIdentityFromMnemonic(typed).publicKey).toBe(deriveIdentityFromMnemonic(MNEMONIC).publicKey);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('rejects an invalid phrase', () => {
|
|
53
|
-
expect(() => deriveIdentityFromMnemonic('abandon abandon abandon')).toThrow('Invalid recovery phrase');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('generates a fresh 12-word identity in canonical public-key form', () => {
|
|
57
|
-
const identity = generateWebIdentity();
|
|
58
|
-
expect(identity.mnemonic.split(' ')).toHaveLength(12);
|
|
59
|
-
expect(identity.publicKey).toMatch(/^04[0-9a-f]{128}$/);
|
|
60
|
-
expect(generateWebIdentity().publicKey).not.toBe(identity.publicKey);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('uses a fixed 32-byte PRF input', () => {
|
|
64
|
-
expect(WEB_IDENTITY_PRF_INPUT).toHaveLength(32);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('sealing and unlocking', () => {
|
|
69
|
-
it('round-trips: the passkey that sealed it opens it into the same identity', () => {
|
|
70
|
-
const identity = deriveIdentityFromMnemonic(MNEMONIC);
|
|
71
|
-
const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
|
|
72
|
-
wipeBytes(dataKey);
|
|
73
|
-
|
|
74
|
-
// The stored shape is exactly the published contract.
|
|
75
|
-
expect(webIdentityEnvelopeSchema.parse(envelope)).toEqual(envelope);
|
|
76
|
-
// Nothing identifying the secret is visible in the envelope.
|
|
77
|
-
expect(JSON.stringify(envelope)).not.toContain(identity.privateKey);
|
|
78
|
-
|
|
79
|
-
const opened = unlockWebIdentity(envelope, prf(7), CREDENTIAL_A);
|
|
80
|
-
expect(opened).toEqual(identity);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('refuses to seal a phrase under a public key it does not belong to', () => {
|
|
84
|
-
const other = generateWebIdentity();
|
|
85
|
-
expect(() =>
|
|
86
|
-
sealWebIdentity({ ...deriveIdentityFromMnemonic(MNEMONIC), publicKey: other.publicKey }, { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' }),
|
|
87
|
-
).toThrow('does not belong');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('reports an unregistered passkey as unknown-credential', () => {
|
|
91
|
-
const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
|
|
92
|
-
expect(failureOf(() => unlockWebIdentity(envelope, prf(7), CREDENTIAL_B))).toBe('unknown-credential');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('reports a registered passkey returning a different PRF value as prf-mismatch (recoverable)', () => {
|
|
96
|
-
const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
|
|
97
|
-
expect(failureOf(() => unlockWebIdentity(envelope, prf(8), CREDENTIAL_A))).toBe('prf-mismatch');
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('refuses a wrap transplanted onto another credential id', () => {
|
|
101
|
-
const { envelope } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(7), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
|
|
102
|
-
const transplanted = { ...envelope, wraps: [{ ...envelope.wraps[0], credentialId: CREDENTIAL_B }] };
|
|
103
|
-
// The associated data binds each wrap to its credential.
|
|
104
|
-
expect(failureOf(() => unlockWebIdentity(transplanted, prf(7), CREDENTIAL_B))).toBe('prf-mismatch');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('refuses an envelope re-labelled with another identity public key', () => {
|
|
108
|
-
const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), {
|
|
109
|
-
prfOutput: prf(7),
|
|
110
|
-
credentialId: CREDENTIAL_A,
|
|
111
|
-
rpId: 'oxy.so',
|
|
112
|
-
});
|
|
113
|
-
const relabelled = { ...envelope, publicKey: generateWebIdentity().publicKey };
|
|
114
|
-
expect(failureOf(() => openWebIdentity(relabelled, dataKey))).toBe('corrupt');
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('treats a tampered sealed secret as corrupt, never as a different identity', () => {
|
|
118
|
-
const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), {
|
|
119
|
-
prfOutput: prf(7),
|
|
120
|
-
credentialId: CREDENTIAL_A,
|
|
121
|
-
rpId: 'oxy.so',
|
|
122
|
-
});
|
|
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');
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
describe('more than one passkey', () => {
|
|
129
|
-
it('opens with every registered passkey and keeps a single sealed identity', () => {
|
|
130
|
-
const identity = deriveIdentityFromMnemonic(MNEMONIC);
|
|
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' });
|
|
133
|
-
|
|
134
|
-
expect(withTwo.wraps.map((wrap) => wrap.credentialId)).toEqual([CREDENTIAL_A, CREDENTIAL_B]);
|
|
135
|
-
expect(withTwo.sealedSecret).toBe(sealed.envelope.sealedSecret);
|
|
136
|
-
expect(unlockWebIdentity(withTwo, prf(1), CREDENTIAL_A).publicKey).toBe(identity.publicKey);
|
|
137
|
-
expect(unlockWebIdentity(withTwo, prf(2), CREDENTIAL_B).publicKey).toBe(identity.publicKey);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('replaces a passkey’s wrap instead of duplicating it', () => {
|
|
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' });
|
|
143
|
-
expect(rewrapped.wraps).toHaveLength(1);
|
|
144
|
-
expect(failureOf(() => unlockWebIdentity(rewrapped, prf(1), CREDENTIAL_A))).toBe('prf-mismatch');
|
|
145
|
-
expect(unlockWebIdentity(rewrapped, prf(9), CREDENTIAL_A).mnemonic).toBe(MNEMONIC);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('will not add a wrap with a data key from a different envelope', () => {
|
|
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();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('removes a passkey, but never the last one', () => {
|
|
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' });
|
|
157
|
-
const withOne = removeWrap(withTwo, CREDENTIAL_A);
|
|
158
|
-
|
|
159
|
-
expect(withOne.wraps.map((wrap) => wrap.credentialId)).toEqual([CREDENTIAL_B]);
|
|
160
|
-
expect(() => removeWrap(withOne, CREDENTIAL_B)).toThrow('at least one passkey');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('keeps the data key usable only until it is wiped', () => {
|
|
164
|
-
const sealed = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC), { prfOutput: prf(1), credentialId: CREDENTIAL_A, rpId: 'oxy.so' });
|
|
165
|
-
const recovered = unwrapDataKey(sealed.envelope, prf(1), CREDENTIAL_A);
|
|
166
|
-
wipeBytes(recovered);
|
|
167
|
-
expect(failureOf(() => openWebIdentity(sealed.envelope, recovered))).toBe('corrupt');
|
|
168
|
-
});
|
|
169
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Envelopes (ADR 0024 D2, D5): one root, whatever material it came from — a 12-
|
|
3
|
-
* or 24-word phrase, or a raw key that must never gain a phrase — and wraps bound
|
|
4
|
-
* to the RP ID their passkey lives under.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { webIdentityEnvelopeSchema } from '@oxy.so/contracts';
|
|
8
|
-
import { mnemonicToSeedSync } from '@scure/bip39';
|
|
9
|
-
import { bytesToHex } from '@noble/hashes/utils';
|
|
10
|
-
import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
|
|
11
|
-
import {
|
|
12
|
-
addWrap,
|
|
13
|
-
deriveIdentityFromMnemonic,
|
|
14
|
-
deriveIdentityFromPrivateKey,
|
|
15
|
-
deriveIdentityFromRecoveryMaterial,
|
|
16
|
-
isUsablePrfOutput,
|
|
17
|
-
markWrapVerified,
|
|
18
|
-
parseRecoveryMaterial,
|
|
19
|
-
sealWebIdentity,
|
|
20
|
-
unlockWebIdentity,
|
|
21
|
-
unwrapDataKey,
|
|
22
|
-
WebIdentityUnlockError,
|
|
23
|
-
wipeBytes,
|
|
24
|
-
} from '../webIdentityCarrier';
|
|
25
|
-
|
|
26
|
-
const MNEMONIC_12 = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
27
|
-
/** BIP-39 reference vector: 32 bytes of zero entropy. */
|
|
28
|
-
const MNEMONIC_24 =
|
|
29
|
-
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
|
|
30
|
-
const RAW_KEY = '1f'.repeat(32);
|
|
31
|
-
const prf = (fill: number): Uint8Array => new Uint8Array(32).fill(fill);
|
|
32
|
-
const CREDENTIAL = 'credential-aaaaaaaaaaaaaaaa';
|
|
33
|
-
|
|
34
|
-
const failureOf = (fn: () => unknown): string | undefined => {
|
|
35
|
-
try {
|
|
36
|
-
fn();
|
|
37
|
-
} catch (error) {
|
|
38
|
-
return error instanceof WebIdentityUnlockError ? error.failure : 'other';
|
|
39
|
-
}
|
|
40
|
-
return undefined;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
describe('recovery material', () => {
|
|
44
|
-
it('derives a 24-word root exactly as seed[0:32], unchanged from the 12-word rule', () => {
|
|
45
|
-
const identity = deriveIdentityFromMnemonic(MNEMONIC_24);
|
|
46
|
-
const expected = bytesToHex(mnemonicToSeedSync(MNEMONIC_24).slice(0, 32));
|
|
47
|
-
expect(identity.kind).toBe('mnemonic');
|
|
48
|
-
expect(identity.privateKey).toBe(expected);
|
|
49
|
-
expect(identity.publicKey).toBe(deriveSecp256k1PublicKey(expected));
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('reads a phrase or a raw key, and nothing else', () => {
|
|
53
|
-
expect(parseRecoveryMaterial(` ${MNEMONIC_24.toUpperCase()} `)).toEqual({ kind: 'mnemonic', mnemonic: MNEMONIC_24 });
|
|
54
|
-
expect(parseRecoveryMaterial(`0x${RAW_KEY.toUpperCase()}`)).toEqual({ kind: 'raw-key', privateKey: `0x${RAW_KEY.toUpperCase()}` });
|
|
55
|
-
expect(() => parseRecoveryMaterial('abandon abandon')).toThrow('Invalid recovery material');
|
|
56
|
-
expect(() => parseRecoveryMaterial('zz'.repeat(32))).toThrow('Invalid recovery material');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('keeps a raw key a raw key: no phrase is ever derived for it', () => {
|
|
60
|
-
const identity = deriveIdentityFromRecoveryMaterial({ kind: 'raw-key', privateKey: `0x${RAW_KEY}` });
|
|
61
|
-
expect(identity).toEqual({ kind: 'raw-key', mnemonic: null, privateKey: RAW_KEY, publicKey: deriveSecp256k1PublicKey(RAW_KEY) });
|
|
62
|
-
expect(() => deriveIdentityFromPrivateKey('00'.repeat(32))).toThrow('Invalid private key');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('treats only an actual 32-byte PRF output as usable', () => {
|
|
66
|
-
expect(isUsablePrfOutput(prf(1))).toBe(true);
|
|
67
|
-
expect(isUsablePrfOutput(new Uint8Array(31))).toBe(false);
|
|
68
|
-
expect(isUsablePrfOutput(null)).toBe(false);
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('envelopes', () => {
|
|
73
|
-
it.each([
|
|
74
|
-
['12 words', () => deriveIdentityFromMnemonic(MNEMONIC_12)],
|
|
75
|
-
['24 words', () => deriveIdentityFromMnemonic(MNEMONIC_24)],
|
|
76
|
-
['a raw key', () => deriveIdentityFromPrivateKey(RAW_KEY)],
|
|
77
|
-
])('seals and reopens the same root from %s', (_label, make) => {
|
|
78
|
-
const identity = make();
|
|
79
|
-
const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(7), credentialId: CREDENTIAL, rpId: 'oxy.so' });
|
|
80
|
-
wipeBytes(dataKey);
|
|
81
|
-
|
|
82
|
-
expect(webIdentityEnvelopeSchema.safeParse(envelope).success).toBe(true);
|
|
83
|
-
expect(envelope.version).toBe(2);
|
|
84
|
-
expect(envelope.wraps[0].rpId).toBe('oxy.so');
|
|
85
|
-
const opened = unlockWebIdentity(envelope, prf(7), CREDENTIAL);
|
|
86
|
-
expect(opened).toEqual(make());
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('binds the RP ID into the wrap, so a relabelled wrap does not open', () => {
|
|
90
|
-
const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC_12), { prfOutput: prf(3), credentialId: CREDENTIAL, rpId: 'oxy.so' });
|
|
91
|
-
wipeBytes(dataKey);
|
|
92
|
-
const relabelled = { ...envelope, wraps: envelope.wraps.map((wrap) => ({ ...wrap, rpId: 'auth.oxy.so' })) };
|
|
93
|
-
expect(failureOf(() => unwrapDataKey(relabelled, prf(3), CREDENTIAL))).toBe('prf-mismatch');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('refuses a secret kind that was swapped after sealing', () => {
|
|
97
|
-
const { envelope, dataKey } = sealWebIdentity(deriveIdentityFromMnemonic(MNEMONIC_24), { prfOutput: prf(3), credentialId: CREDENTIAL });
|
|
98
|
-
const swapped = { ...envelope, secretKind: 'raw-private-key' as const };
|
|
99
|
-
expect(failureOf(() => unlockWebIdentity(swapped, prf(3), CREDENTIAL))).toBe('corrupt');
|
|
100
|
-
wipeBytes(dataKey);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('adds a second passkey and records which wraps have proven they open the root', () => {
|
|
104
|
-
const identity = deriveIdentityFromMnemonic(MNEMONIC_12);
|
|
105
|
-
const { envelope, dataKey } = sealWebIdentity(identity, { prfOutput: prf(1), credentialId: CREDENTIAL, rpId: 'oxy.so' });
|
|
106
|
-
const second = 'credential-bbbbbbbbbbbbbbbb';
|
|
107
|
-
const both = addWrap(envelope, dataKey, { prfOutput: prf(2), credentialId: second, rpId: 'oxy.so' });
|
|
108
|
-
wipeBytes(dataKey);
|
|
109
|
-
|
|
110
|
-
expect(unlockWebIdentity(both, prf(2), second).publicKey).toBe(identity.publicKey);
|
|
111
|
-
const verified = markWrapVerified(both, second, new Date('2026-09-16T00:00:00.000Z'));
|
|
112
|
-
expect(verified.wraps.find((wrap) => wrap.credentialId === second)?.verifiedAt).toBe('2026-09-16T00:00:00.000Z');
|
|
113
|
-
expect(verified.wraps.find((wrap) => wrap.credentialId === CREDENTIAL)?.verifiedAt).toBeUndefined();
|
|
114
|
-
// Holder metadata is not part of the AEAD: marking does not change what opens.
|
|
115
|
-
expect(unlockWebIdentity(verified, prf(1), CREDENTIAL).publicKey).toBe(identity.publicKey);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Identity move — the crypto of giving a web root to Commons (ADR 0024 D6).
|
|
3
|
-
*
|
|
4
|
-
* Both sides run this: the web holder (the initiator, holding the root) and
|
|
5
|
-
* Commons (the responder, receiving it). The relay in between sees a commitment,
|
|
6
|
-
* two ephemeral public keys and ciphertext. The initiator commits to its key
|
|
7
|
-
* before the responder chooses one and reveals it only afterwards, so the 6-digit
|
|
8
|
-
* code both screens show exposes a relay that substituted a key — it cannot grind
|
|
9
|
-
* one until the codes agree. The receipt binds the move, the root, both keys and
|
|
10
|
-
* the ciphertext actually relayed.
|
|
11
|
-
*
|
|
12
|
-
* PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import './polyfill';
|
|
16
|
-
import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
|
|
17
|
-
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
18
|
-
import { sha256 } from '@noble/hashes/sha256';
|
|
19
|
-
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
20
|
-
import { verifySignature } from '@oxy.so/protocol';
|
|
21
|
-
import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
|
|
22
|
-
import {
|
|
23
|
-
IDENTITY_MOVE_QR_PREFIX,
|
|
24
|
-
buildMoveCiphertextDigestInput,
|
|
25
|
-
buildMoveCommitmentInput,
|
|
26
|
-
buildMoveReceiptMessage,
|
|
27
|
-
buildMoveSasInput,
|
|
28
|
-
} from '@oxy.so/contracts';
|
|
29
|
-
import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead';
|
|
30
|
-
import { deriveSharedSecret } from './ecdh';
|
|
31
|
-
import { hkdfSha256 } from './kdf';
|
|
32
|
-
import { deriveIdentityFromMnemonic, wipeBytes, type OpenedMnemonicIdentity } from './webIdentityCarrier';
|
|
33
|
-
|
|
34
|
-
const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
|
|
35
|
-
|
|
36
|
-
function sha256Hex(input: string): string {
|
|
37
|
-
return bytesToHex(sha256(utf8ToBytes(input)));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
|
|
41
|
-
export function generateMoveEphemeralKeyPair(): { privateKey: string; publicKey: string } {
|
|
42
|
-
return generateSecp256k1KeyPair();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
|
|
46
|
-
export function deriveMoveKey(ownEphemeralPrivateKey: string, otherEphemeralPublicKey: string, moveId: string): Uint8Array {
|
|
47
|
-
const shared = deriveSharedSecret(ownEphemeralPrivateKey, otherEphemeralPublicKey);
|
|
48
|
-
try {
|
|
49
|
-
return hkdfSha256(shared, utf8ToBytes(moveId.toLowerCase()), MOVE_KDF_INFO, AEAD_KEY_LENGTH);
|
|
50
|
-
} finally {
|
|
51
|
-
wipeBytes(shared);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Initiator: commit to the ephemeral key before anyone else's key is known. The nonce stays in memory until the reveal. */
|
|
56
|
-
export function createMoveCommitment(initiatorEphemeralPublicKey: string): { commitment: string; nonce: string } {
|
|
57
|
-
const nonceBytes = new Uint8Array(32);
|
|
58
|
-
globalThis.crypto.getRandomValues(nonceBytes);
|
|
59
|
-
const nonce = bytesToHex(nonceBytes);
|
|
60
|
-
return { commitment: sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)), nonce };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Responder: does the revealed key match the commitment read before joining? */
|
|
64
|
-
export function verifyMoveCommitment(initiatorEphemeralPublicKey: string, nonce: string, commitment: string): boolean {
|
|
65
|
-
return sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)) === commitment.toLowerCase();
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** The 6-digit code, bound to the move, both keys in their roles, and the commitment. */
|
|
69
|
-
export function deriveMoveSas(input: {
|
|
70
|
-
moveId: string;
|
|
71
|
-
initiatorEphemeralPublicKey: string;
|
|
72
|
-
responderEphemeralPublicKey: string;
|
|
73
|
-
initiatorCommitment: string;
|
|
74
|
-
}): string {
|
|
75
|
-
const digest = sha256(utf8ToBytes(buildMoveSasInput(input)));
|
|
76
|
-
const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
|
|
77
|
-
return String(value % 1_000_000).padStart(6, '0');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** The digest a receipt binds: what was actually relayed. */
|
|
81
|
-
export function digestMoveCiphertext(sealed: { nonce: string; ciphertext: string }): string {
|
|
82
|
-
return sha256Hex(buildMoveCiphertextDigestInput(sealed));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface MoveReceiptClaims {
|
|
86
|
-
moveId: string;
|
|
87
|
-
rootPublicKey: string;
|
|
88
|
-
initiatorEphemeralPublicKey: string;
|
|
89
|
-
responderEphemeralPublicKey: string;
|
|
90
|
-
ciphertextDigest: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Responder: sign the receipt with a signer that reads the root back from durable
|
|
95
|
-
* storage (Commons passes its keychain signer), so a receipt exists only for a
|
|
96
|
-
* root that was actually stored.
|
|
97
|
-
*/
|
|
98
|
-
export async function signMoveReceipt(sign: (message: string) => Promise<string>, claims: MoveReceiptClaims): Promise<{ signature: string }> {
|
|
99
|
-
return { signature: await sign(buildMoveReceiptMessage(claims)) };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Initiator: is this receipt the root's, for exactly this move and ciphertext? */
|
|
103
|
-
export function verifyMoveReceipt(claims: MoveReceiptClaims, signature: string): Promise<boolean> {
|
|
104
|
-
return verifySignature(buildMoveReceiptMessage(claims), signature, claims.rootPublicKey);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function moveAad(moveId: string, publicKey: string): Uint8Array {
|
|
108
|
-
return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Initiator: seal the phrase entropy (12–24 words) for the responder. */
|
|
112
|
-
export function sealIdentityForMove(
|
|
113
|
-
identity: Pick<OpenedMnemonicIdentity, 'mnemonic' | 'publicKey'>,
|
|
114
|
-
moveKey: Uint8Array,
|
|
115
|
-
moveId: string,
|
|
116
|
-
): { nonce: string; ciphertext: string } {
|
|
117
|
-
if (typeof identity.mnemonic !== 'string' || !identity.mnemonic) {
|
|
118
|
-
throw new Error('Only an identity with a recovery phrase can be moved this way');
|
|
119
|
-
}
|
|
120
|
-
const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
|
|
121
|
-
try {
|
|
122
|
-
const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
|
|
123
|
-
return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(ciphertext) };
|
|
124
|
-
} finally {
|
|
125
|
-
wipeBytes(entropy);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Responder: open the sealed identity. The derived public key must be the one
|
|
131
|
-
* the move declared — anything else is refused, never imported.
|
|
132
|
-
*/
|
|
133
|
-
export function openMovedIdentity(
|
|
134
|
-
sealed: { nonce: string; ciphertext: string },
|
|
135
|
-
moveKey: Uint8Array,
|
|
136
|
-
moveId: string,
|
|
137
|
-
expectedPublicKey: string,
|
|
138
|
-
): OpenedMnemonicIdentity {
|
|
139
|
-
let entropy: Uint8Array;
|
|
140
|
-
try {
|
|
141
|
-
entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
|
|
142
|
-
} catch {
|
|
143
|
-
throw new Error('The identity could not be opened. The codes may not have matched — start again.');
|
|
144
|
-
}
|
|
145
|
-
try {
|
|
146
|
-
const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
|
|
147
|
-
if (identity.publicKey !== expectedPublicKey.toLowerCase()) {
|
|
148
|
-
throw new Error('The received identity is not the one being moved');
|
|
149
|
-
}
|
|
150
|
-
return identity;
|
|
151
|
-
} finally {
|
|
152
|
-
wipeBytes(entropy);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** The QR payload for a move. */
|
|
157
|
-
export function buildMoveQrPayload(moveId: string): string {
|
|
158
|
-
return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** The move id in a scanned payload, or `null` when it is not a move QR. */
|
|
162
|
-
export function parseMoveQrPayload(raw: string): string | null {
|
|
163
|
-
const value = raw.trim();
|
|
164
|
-
if (!value.toLowerCase().startsWith(IDENTITY_MOVE_QR_PREFIX)) return null;
|
|
165
|
-
const id = value.slice(IDENTITY_MOVE_QR_PREFIX.length).toLowerCase();
|
|
166
|
-
return /^[0-9a-f]{32}$/.test(id) ? id : null;
|
|
167
|
-
}
|