@oxy.so/core 1.5.1 → 1.6.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 +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/traffic.js +29 -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/traffic.js +29 -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/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/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 +2 -2
- package/src/HttpService.ts +74 -9
- package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -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/__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__/traffic.test.ts +132 -1
- 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/traffic.ts +31 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Identity move — the crypto of
|
|
2
|
+
* Identity move — the crypto of giving a web root to Commons (ADR 0024 D6).
|
|
3
3
|
*
|
|
4
|
-
* Both
|
|
5
|
-
* Commons (the responder, receiving it). The relay in between sees
|
|
6
|
-
* public keys and ciphertext
|
|
7
|
-
*
|
|
4
|
+
* Both sides run this: the web holder (the initiator, holding the root) and
|
|
5
|
+
* Commons (the responder, receiving it). The relay in between sees a commitment,
|
|
6
|
+
* two ephemeral public keys and ciphertext. The initiator commits to its key
|
|
7
|
+
* before the responder chooses one and reveals it only afterwards, so the 6-digit
|
|
8
|
+
* code both screens show exposes a relay that substituted a key — it cannot grind
|
|
9
|
+
* one until the codes agree. The receipt binds the move, the root, both keys and
|
|
10
|
+
* the ciphertext actually relayed.
|
|
8
11
|
*
|
|
9
12
|
* PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
|
|
10
13
|
*/
|
|
@@ -12,22 +15,27 @@
|
|
|
12
15
|
import './polyfill';
|
|
13
16
|
import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
|
|
14
17
|
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
18
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
15
19
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
16
|
-
import {
|
|
20
|
+
import { verifySignature } from '@oxy.so/protocol';
|
|
17
21
|
import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
|
|
18
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
IDENTITY_MOVE_QR_PREFIX,
|
|
24
|
+
buildMoveCiphertextDigestInput,
|
|
25
|
+
buildMoveCommitmentInput,
|
|
26
|
+
buildMoveReceiptMessage,
|
|
27
|
+
buildMoveSasInput,
|
|
28
|
+
} from '@oxy.so/contracts';
|
|
19
29
|
import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead';
|
|
20
30
|
import { deriveSharedSecret } from './ecdh';
|
|
21
31
|
import { hkdfSha256 } from './kdf';
|
|
22
|
-
import { deriveIdentityFromMnemonic,
|
|
32
|
+
import { deriveIdentityFromMnemonic, wipeBytes, type OpenedMnemonicIdentity } from './webIdentityCarrier';
|
|
23
33
|
|
|
24
34
|
const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
received: 'identity_move_received',
|
|
30
|
-
} as const;
|
|
36
|
+
function sha256Hex(input: string): string {
|
|
37
|
+
return bytesToHex(sha256(utf8ToBytes(input)));
|
|
38
|
+
}
|
|
31
39
|
|
|
32
40
|
/** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
|
|
33
41
|
export function generateMoveEphemeralKeyPair(): { privateKey: string; publicKey: string } {
|
|
@@ -44,21 +52,71 @@ export function deriveMoveKey(ownEphemeralPrivateKey: string, otherEphemeralPubl
|
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
/**
|
|
48
|
-
export function
|
|
49
|
-
|
|
55
|
+
/** Initiator: commit to the ephemeral key before anyone else's key is known. The nonce stays in memory until the reveal. */
|
|
56
|
+
export function createMoveCommitment(initiatorEphemeralPublicKey: string): { commitment: string; nonce: string } {
|
|
57
|
+
const nonceBytes = new Uint8Array(32);
|
|
58
|
+
globalThis.crypto.getRandomValues(nonceBytes);
|
|
59
|
+
const nonce = bytesToHex(nonceBytes);
|
|
60
|
+
return { commitment: sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)), nonce };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Responder: does the revealed key match the commitment read before joining? */
|
|
64
|
+
export function verifyMoveCommitment(initiatorEphemeralPublicKey: string, nonce: string, commitment: string): boolean {
|
|
65
|
+
return sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)) === commitment.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The 6-digit code, bound to the move, both keys in their roles, and the commitment. */
|
|
69
|
+
export function deriveMoveSas(input: {
|
|
70
|
+
moveId: string;
|
|
71
|
+
initiatorEphemeralPublicKey: string;
|
|
72
|
+
responderEphemeralPublicKey: string;
|
|
73
|
+
initiatorCommitment: string;
|
|
74
|
+
}): string {
|
|
75
|
+
const digest = sha256(utf8ToBytes(buildMoveSasInput(input)));
|
|
76
|
+
const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
|
|
77
|
+
return String(value % 1_000_000).padStart(6, '0');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The digest a receipt binds: what was actually relayed. */
|
|
81
|
+
export function digestMoveCiphertext(sealed: { nonce: string; ciphertext: string }): string {
|
|
82
|
+
return sha256Hex(buildMoveCiphertextDigestInput(sealed));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface MoveReceiptClaims {
|
|
86
|
+
moveId: string;
|
|
87
|
+
rootPublicKey: string;
|
|
88
|
+
initiatorEphemeralPublicKey: string;
|
|
89
|
+
responderEphemeralPublicKey: string;
|
|
90
|
+
ciphertextDigest: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Responder: sign the receipt with a signer that reads the root back from durable
|
|
95
|
+
* storage (Commons passes its keychain signer), so a receipt exists only for a
|
|
96
|
+
* root that was actually stored.
|
|
97
|
+
*/
|
|
98
|
+
export async function signMoveReceipt(sign: (message: string) => Promise<string>, claims: MoveReceiptClaims): Promise<{ signature: string }> {
|
|
99
|
+
return { signature: await sign(buildMoveReceiptMessage(claims)) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Initiator: is this receipt the root's, for exactly this move and ciphertext? */
|
|
103
|
+
export function verifyMoveReceipt(claims: MoveReceiptClaims, signature: string): Promise<boolean> {
|
|
104
|
+
return verifySignature(buildMoveReceiptMessage(claims), signature, claims.rootPublicKey);
|
|
50
105
|
}
|
|
51
106
|
|
|
52
107
|
function moveAad(moveId: string, publicKey: string): Uint8Array {
|
|
53
108
|
return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
|
|
54
109
|
}
|
|
55
110
|
|
|
56
|
-
/** Initiator: seal the
|
|
111
|
+
/** Initiator: seal the phrase entropy (12–24 words) for the responder. */
|
|
57
112
|
export function sealIdentityForMove(
|
|
58
|
-
identity: Pick<
|
|
113
|
+
identity: Pick<OpenedMnemonicIdentity, 'mnemonic' | 'publicKey'>,
|
|
59
114
|
moveKey: Uint8Array,
|
|
60
115
|
moveId: string,
|
|
61
116
|
): { nonce: string; ciphertext: string } {
|
|
117
|
+
if (typeof identity.mnemonic !== 'string' || !identity.mnemonic) {
|
|
118
|
+
throw new Error('Only an identity with a recovery phrase can be moved this way');
|
|
119
|
+
}
|
|
62
120
|
const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
|
|
63
121
|
try {
|
|
64
122
|
const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
|
|
@@ -77,7 +135,7 @@ export function openMovedIdentity(
|
|
|
77
135
|
moveKey: Uint8Array,
|
|
78
136
|
moveId: string,
|
|
79
137
|
expectedPublicKey: string,
|
|
80
|
-
):
|
|
138
|
+
): OpenedMnemonicIdentity {
|
|
81
139
|
let entropy: Uint8Array;
|
|
82
140
|
try {
|
|
83
141
|
entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
|
|
@@ -95,30 +153,6 @@ export function openMovedIdentity(
|
|
|
95
153
|
}
|
|
96
154
|
}
|
|
97
155
|
|
|
98
|
-
/** The exact bytes a move proof or receipt signs. */
|
|
99
|
-
export function buildMoveMessage(action: string, moveId: string, timestamp: number): string {
|
|
100
|
-
return JSON.stringify({ action, moveId: moveId.toLowerCase(), timestamp });
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Sign a move action with the identity key. */
|
|
104
|
-
export async function signMoveAction(
|
|
105
|
-
identity: Pick<OpenedWebIdentity, 'privateKey'>,
|
|
106
|
-
action: string,
|
|
107
|
-
moveId: string,
|
|
108
|
-
timestamp: number = Date.now(),
|
|
109
|
-
): Promise<{ signature: string; timestamp: number }> {
|
|
110
|
-
return { signature: await signMessage(buildMoveMessage(action, moveId, timestamp), identity.privateKey), timestamp };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Initiator: is this receipt really from the identity? Checked locally with the
|
|
115
|
-
* identity's public key, so a server that claims "completed" without Commons
|
|
116
|
-
* having the key cannot make the web destroy its copy.
|
|
117
|
-
*/
|
|
118
|
-
export function verifyMoveReceipt(publicKey: string, moveId: string, receipt: { signature: string; timestamp: number }): Promise<boolean> {
|
|
119
|
-
return verifySignature(buildMoveMessage(IDENTITY_MOVE_ACTIONS.received, moveId, receipt.timestamp), receipt.signature, publicKey);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
156
|
/** The QR payload for a move. */
|
|
123
157
|
export function buildMoveQrPayload(moveId: string): string {
|
|
124
158
|
return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing the one identity-proof format (ADR 0024 D7).
|
|
3
|
+
*
|
|
4
|
+
* The bytes come from `@oxy.so/contracts` `buildIdentityProofMessage`, which the
|
|
5
|
+
* API verifies against; payload digests hash `canonicalJson`. Nothing here builds
|
|
6
|
+
* its own JSON.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import './polyfill';
|
|
10
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
11
|
+
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
|
|
12
|
+
import { signMessage } from '@oxy.so/protocol';
|
|
13
|
+
import {
|
|
14
|
+
IDENTITY_PROOF_VERSION,
|
|
15
|
+
buildIdentityProofMessage,
|
|
16
|
+
canonicalJson,
|
|
17
|
+
type IdentityProof,
|
|
18
|
+
type IdentityProofClaims,
|
|
19
|
+
} from '@oxy.so/contracts';
|
|
20
|
+
|
|
21
|
+
/** SHA-256 hex of the canonical JSON of `payload` — the `payloadDigest` claim. */
|
|
22
|
+
export function digestIdentityPayload(payload: unknown): string {
|
|
23
|
+
return bytesToHex(sha256(utf8ToBytes(canonicalJson(payload))));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Sign `claims` with the root, producing the proof as it travels. */
|
|
27
|
+
export async function signIdentityProof(
|
|
28
|
+
identity: { privateKey: string; publicKey: string },
|
|
29
|
+
claims: IdentityProofClaims,
|
|
30
|
+
): Promise<IdentityProof> {
|
|
31
|
+
if (claims.rootPublicKey !== identity.publicKey.toLowerCase()) {
|
|
32
|
+
throw new Error('identity proof: the claims name a different root');
|
|
33
|
+
}
|
|
34
|
+
const signature = await signMessage(buildIdentityProofMessage(claims), identity.privateKey);
|
|
35
|
+
return { v: IDENTITY_PROOF_VERSION, challenge: claims.challenge, expiresAt: claims.expiresAt, signature };
|
|
36
|
+
}
|