@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
package/dist/cjs/HttpService.js
CHANGED
|
@@ -83,6 +83,33 @@ const TOKEN_REFRESH_COOLDOWN_MS = 15000;
|
|
|
83
83
|
* ~15× faster without weakening the storm guard.
|
|
84
84
|
*/
|
|
85
85
|
const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
|
|
86
|
+
/**
|
|
87
|
+
* Cooldown (ms) applied after the refresh endpoint answered 429 Too Many
|
|
88
|
+
* Requests. Overrides BOTH cooldowns above, because neither of them is about
|
|
89
|
+
* this failure.
|
|
90
|
+
*
|
|
91
|
+
* The other two ask "is the current token still usable?" and answer a server
|
|
92
|
+
* that is unreachable or erroring. A 429 is the opposite situation: the server
|
|
93
|
+
* is perfectly reachable and is telling this client, specifically, that it is
|
|
94
|
+
* asking too often. `EXPIRED_TOKEN_REFRESH_COOLDOWN_MS` then becomes actively
|
|
95
|
+
* harmful — an expired token drives the request-time preflight and the 401
|
|
96
|
+
* retry lanes at one mint per second (60/min) against a mint budget of 30/min,
|
|
97
|
+
* so the client's own retries hold the limiter tripped and it can never fall
|
|
98
|
+
* back under the ceiling. The limit stops being self-clearing and becomes
|
|
99
|
+
* self-sustaining for as long as the app keeps issuing requests.
|
|
100
|
+
*
|
|
101
|
+
* Sized to the mint limiter's own window (60s, `packages/api/src/routes/sessionDevice.ts`)
|
|
102
|
+
* so ONE attempt lands per window: enough to discover the moment the budget has
|
|
103
|
+
* drained, never enough to consume it. The per-device lockout answers 429 for
|
|
104
|
+
* far longer (15 min), and that is fine — one probe a minute against it is
|
|
105
|
+
* negligible traffic and the session recovers the instant the lockout lifts.
|
|
106
|
+
*
|
|
107
|
+
* NOT routed through the scheduler's exponential backoff: that backoff is
|
|
108
|
+
* effect-local to `startTokenRefreshScheduler` and governs only the scheduled
|
|
109
|
+
* lane, while the lanes that actually produce the storm here (preflight, 401
|
|
110
|
+
* retry) are request-driven and are throttled solely by this cooldown.
|
|
111
|
+
*/
|
|
112
|
+
const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60000;
|
|
86
113
|
/**
|
|
87
114
|
* Lead time (seconds) before access-token expiry at which a preflight refresh
|
|
88
115
|
* is triggered. A token within this window of `exp` is treated as effectively
|
|
@@ -164,6 +191,17 @@ class HttpService {
|
|
|
164
191
|
* fixed deadline that could not shrink once the token expired mid-cooldown.
|
|
165
192
|
*/
|
|
166
193
|
this.lastRefreshFailureAt = 0;
|
|
194
|
+
/**
|
|
195
|
+
* Whether the last refresh failure was the server answering 429. Selects
|
|
196
|
+
* {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} over the two token-state cooldowns
|
|
197
|
+
* for the NEXT attempt, and is cleared by any success or any other failure.
|
|
198
|
+
*
|
|
199
|
+
* A boolean beside {@link lastRefreshFailureAt} rather than a stored deadline,
|
|
200
|
+
* for the same reason that field gives: the cooldown stays a function of the
|
|
201
|
+
* current state, so it can still shorten if the situation changes underneath
|
|
202
|
+
* it, and there is exactly one timestamp to reason about.
|
|
203
|
+
*/
|
|
204
|
+
this.lastRefreshWasRateLimited = false;
|
|
167
205
|
this.authRefreshHandler = null;
|
|
168
206
|
this.accessTokenProvider = null;
|
|
169
207
|
this.deviceSecretMintInFlight = null;
|
|
@@ -887,19 +925,28 @@ class HttpService {
|
|
|
887
925
|
if (!this.authRefreshHandler) {
|
|
888
926
|
return null;
|
|
889
927
|
}
|
|
890
|
-
// Post-failure cooldown. A
|
|
891
|
-
//
|
|
892
|
-
// expired
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
928
|
+
// Post-failure cooldown. A 429 names its own window regardless of token
|
|
929
|
+
// state — the server is reachable and is rationing this client, so retrying
|
|
930
|
+
// at the expired-token rate would spend the very budget being waited on.
|
|
931
|
+
// Otherwise a genuinely EXPIRED current token uses a much shorter cooldown
|
|
932
|
+
// than a still-valid (proactive, near-expiry) one: an expired token is
|
|
933
|
+
// unusable, so re-mint as soon as the endpoint is reachable again rather
|
|
934
|
+
// than waiting out the full window while requests carry a stale bearer. All
|
|
935
|
+
// three are measured from the last failure, so the moment a still-valid
|
|
936
|
+
// token crosses `exp` mid-cooldown the shorter window applies.
|
|
937
|
+
const cooldownMs = this.lastRefreshWasRateLimited
|
|
938
|
+
? RATE_LIMITED_REFRESH_COOLDOWN_MS
|
|
939
|
+
: this.isAccessTokenExpired()
|
|
940
|
+
? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
|
|
941
|
+
: TOKEN_REFRESH_COOLDOWN_MS;
|
|
899
942
|
if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
|
|
900
943
|
return null;
|
|
901
944
|
}
|
|
902
945
|
if (!this.tokenRefreshPromise) {
|
|
946
|
+
// Cleared before the attempt, never after it: the handler reports a 429
|
|
947
|
+
// by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
|
|
948
|
+
// on the way out would discard the flag it just set.
|
|
949
|
+
this.lastRefreshWasRateLimited = false;
|
|
903
950
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
904
951
|
.then((newToken) => {
|
|
905
952
|
if (!newToken) {
|
|
@@ -913,6 +960,7 @@ class HttpService {
|
|
|
913
960
|
// A success clears the failure timestamp so the next refresh is never
|
|
914
961
|
// throttled by a stale cooldown.
|
|
915
962
|
this.lastRefreshFailureAt = 0;
|
|
963
|
+
this.lastRefreshWasRateLimited = false;
|
|
916
964
|
this.logger.debug('Token refreshed via the auth refresh handler');
|
|
917
965
|
return newToken;
|
|
918
966
|
})
|
|
@@ -927,6 +975,21 @@ class HttpService {
|
|
|
927
975
|
}
|
|
928
976
|
return this.tokenRefreshPromise;
|
|
929
977
|
}
|
|
978
|
+
/**
|
|
979
|
+
* Report that the refresh/mint endpoint answered 429 Too Many Requests, so the
|
|
980
|
+
* next attempt waits {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} instead of one of
|
|
981
|
+
* the token-state cooldowns.
|
|
982
|
+
*
|
|
983
|
+
* Called by the refresh handler (`refreshDeviceSecretArm`) from inside the
|
|
984
|
+
* `authRefreshHandler` call, because the handler's `Promise<string | null>`
|
|
985
|
+
* contract cannot carry WHY a refresh failed and 429 is the one failure whose
|
|
986
|
+
* correct retry interval is set by the server rather than by the token.
|
|
987
|
+
*
|
|
988
|
+
* Public because the handler lives in `session/refresh.ts`, not on this class.
|
|
989
|
+
*/
|
|
990
|
+
noteRefreshRateLimited() {
|
|
991
|
+
this.lastRefreshWasRateLimited = true;
|
|
992
|
+
}
|
|
930
993
|
/**
|
|
931
994
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
932
995
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Identity move — the crypto of
|
|
3
|
+
* Identity move — the crypto of giving a web root to Commons (ADR 0024 D6).
|
|
4
4
|
*
|
|
5
|
-
* Both
|
|
6
|
-
* Commons (the responder, receiving it). The relay in between sees
|
|
7
|
-
* public keys and ciphertext
|
|
8
|
-
*
|
|
5
|
+
* Both sides run this: the web holder (the initiator, holding the root) and
|
|
6
|
+
* Commons (the responder, receiving it). The relay in between sees a commitment,
|
|
7
|
+
* two ephemeral public keys and ciphertext. The initiator commits to its key
|
|
8
|
+
* before the responder chooses one and reveals it only afterwards, so the 6-digit
|
|
9
|
+
* code both screens show exposes a relay that substituted a key — it cannot grind
|
|
10
|
+
* one until the codes agree. The receipt binds the move, the root, both keys and
|
|
11
|
+
* the ciphertext actually relayed.
|
|
9
12
|
*
|
|
10
13
|
* PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
|
|
11
14
|
*/
|
|
12
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.IDENTITY_MOVE_ACTIONS = void 0;
|
|
14
16
|
exports.generateMoveEphemeralKeyPair = generateMoveEphemeralKeyPair;
|
|
15
17
|
exports.deriveMoveKey = deriveMoveKey;
|
|
18
|
+
exports.createMoveCommitment = createMoveCommitment;
|
|
19
|
+
exports.verifyMoveCommitment = verifyMoveCommitment;
|
|
16
20
|
exports.deriveMoveSas = deriveMoveSas;
|
|
21
|
+
exports.digestMoveCiphertext = digestMoveCiphertext;
|
|
22
|
+
exports.signMoveReceipt = signMoveReceipt;
|
|
23
|
+
exports.verifyMoveReceipt = verifyMoveReceipt;
|
|
17
24
|
exports.sealIdentityForMove = sealIdentityForMove;
|
|
18
25
|
exports.openMovedIdentity = openMovedIdentity;
|
|
19
|
-
exports.buildMoveMessage = buildMoveMessage;
|
|
20
|
-
exports.signMoveAction = signMoveAction;
|
|
21
|
-
exports.verifyMoveReceipt = verifyMoveReceipt;
|
|
22
26
|
exports.buildMoveQrPayload = buildMoveQrPayload;
|
|
23
27
|
exports.parseMoveQrPayload = parseMoveQrPayload;
|
|
24
28
|
require("./polyfill");
|
|
25
29
|
const bip39_1 = require("@scure/bip39");
|
|
26
30
|
const english_1 = require("@scure/bip39/wordlists/english");
|
|
31
|
+
const sha256_1 = require("@noble/hashes/sha256");
|
|
27
32
|
const utils_1 = require("@noble/hashes/utils");
|
|
28
33
|
const protocol_1 = require("@oxy.so/protocol");
|
|
29
34
|
const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
|
|
@@ -33,11 +38,9 @@ const ecdh_1 = require("./ecdh");
|
|
|
33
38
|
const kdf_1 = require("./kdf");
|
|
34
39
|
const webIdentityCarrier_1 = require("./webIdentityCarrier");
|
|
35
40
|
const MOVE_KDF_INFO = (0, utils_1.utf8ToBytes)('oxy-identity-move-v1');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
received: 'identity_move_received',
|
|
40
|
-
};
|
|
41
|
+
function sha256Hex(input) {
|
|
42
|
+
return (0, utils_1.bytesToHex)((0, sha256_1.sha256)((0, utils_1.utf8ToBytes)(input)));
|
|
43
|
+
}
|
|
41
44
|
/** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
|
|
42
45
|
function generateMoveEphemeralKeyPair() {
|
|
43
46
|
return (0, secp256k1_1.generateSecp256k1KeyPair)();
|
|
@@ -52,15 +55,47 @@ function deriveMoveKey(ownEphemeralPrivateKey, otherEphemeralPublicKey, moveId)
|
|
|
52
55
|
(0, webIdentityCarrier_1.wipeBytes)(shared);
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
|
-
/**
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
+
/** Initiator: commit to the ephemeral key before anyone else's key is known. The nonce stays in memory until the reveal. */
|
|
59
|
+
function createMoveCommitment(initiatorEphemeralPublicKey) {
|
|
60
|
+
const nonceBytes = new Uint8Array(32);
|
|
61
|
+
globalThis.crypto.getRandomValues(nonceBytes);
|
|
62
|
+
const nonce = (0, utils_1.bytesToHex)(nonceBytes);
|
|
63
|
+
return { commitment: sha256Hex((0, contracts_1.buildMoveCommitmentInput)(initiatorEphemeralPublicKey, nonce)), nonce };
|
|
64
|
+
}
|
|
65
|
+
/** Responder: does the revealed key match the commitment read before joining? */
|
|
66
|
+
function verifyMoveCommitment(initiatorEphemeralPublicKey, nonce, commitment) {
|
|
67
|
+
return sha256Hex((0, contracts_1.buildMoveCommitmentInput)(initiatorEphemeralPublicKey, nonce)) === commitment.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
/** The 6-digit code, bound to the move, both keys in their roles, and the commitment. */
|
|
70
|
+
function deriveMoveSas(input) {
|
|
71
|
+
const digest = (0, sha256_1.sha256)((0, utils_1.utf8ToBytes)((0, contracts_1.buildMoveSasInput)(input)));
|
|
72
|
+
const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
|
|
73
|
+
return String(value % 1000000).padStart(6, '0');
|
|
74
|
+
}
|
|
75
|
+
/** The digest a receipt binds: what was actually relayed. */
|
|
76
|
+
function digestMoveCiphertext(sealed) {
|
|
77
|
+
return sha256Hex((0, contracts_1.buildMoveCiphertextDigestInput)(sealed));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Responder: sign the receipt with a signer that reads the root back from durable
|
|
81
|
+
* storage (Commons passes its keychain signer), so a receipt exists only for a
|
|
82
|
+
* root that was actually stored.
|
|
83
|
+
*/
|
|
84
|
+
async function signMoveReceipt(sign, claims) {
|
|
85
|
+
return { signature: await sign((0, contracts_1.buildMoveReceiptMessage)(claims)) };
|
|
86
|
+
}
|
|
87
|
+
/** Initiator: is this receipt the root's, for exactly this move and ciphertext? */
|
|
88
|
+
function verifyMoveReceipt(claims, signature) {
|
|
89
|
+
return (0, protocol_1.verifySignature)((0, contracts_1.buildMoveReceiptMessage)(claims), signature, claims.rootPublicKey);
|
|
58
90
|
}
|
|
59
91
|
function moveAad(moveId, publicKey) {
|
|
60
92
|
return (0, utils_1.utf8ToBytes)(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
|
|
61
93
|
}
|
|
62
|
-
/** Initiator: seal the
|
|
94
|
+
/** Initiator: seal the phrase entropy (12–24 words) for the responder. */
|
|
63
95
|
function sealIdentityForMove(identity, moveKey, moveId) {
|
|
96
|
+
if (typeof identity.mnemonic !== 'string' || !identity.mnemonic) {
|
|
97
|
+
throw new Error('Only an identity with a recovery phrase can be moved this way');
|
|
98
|
+
}
|
|
64
99
|
const entropy = (0, bip39_1.mnemonicToEntropy)(identity.mnemonic, english_1.wordlist);
|
|
65
100
|
try {
|
|
66
101
|
const { nonce, ciphertext } = (0, aead_1.encryptAead)(moveKey, entropy, moveAad(moveId, identity.publicKey));
|
|
@@ -93,22 +128,6 @@ function openMovedIdentity(sealed, moveKey, moveId, expectedPublicKey) {
|
|
|
93
128
|
(0, webIdentityCarrier_1.wipeBytes)(entropy);
|
|
94
129
|
}
|
|
95
130
|
}
|
|
96
|
-
/** The exact bytes a move proof or receipt signs. */
|
|
97
|
-
function buildMoveMessage(action, moveId, timestamp) {
|
|
98
|
-
return JSON.stringify({ action, moveId: moveId.toLowerCase(), timestamp });
|
|
99
|
-
}
|
|
100
|
-
/** Sign a move action with the identity key. */
|
|
101
|
-
async function signMoveAction(identity, action, moveId, timestamp = Date.now()) {
|
|
102
|
-
return { signature: await (0, protocol_1.signMessage)(buildMoveMessage(action, moveId, timestamp), identity.privateKey), timestamp };
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Initiator: is this receipt really from the identity? Checked locally with the
|
|
106
|
-
* identity's public key, so a server that claims "completed" without Commons
|
|
107
|
-
* having the key cannot make the web destroy its copy.
|
|
108
|
-
*/
|
|
109
|
-
function verifyMoveReceipt(publicKey, moveId, receipt) {
|
|
110
|
-
return (0, protocol_1.verifySignature)(buildMoveMessage(exports.IDENTITY_MOVE_ACTIONS.received, moveId, receipt.timestamp), receipt.signature, publicKey);
|
|
111
|
-
}
|
|
112
131
|
/** The QR payload for a move. */
|
|
113
132
|
function buildMoveQrPayload(moveId) {
|
|
114
133
|
return `${contracts_1.IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Signing the one identity-proof format (ADR 0024 D7).
|
|
4
|
+
*
|
|
5
|
+
* The bytes come from `@oxy.so/contracts` `buildIdentityProofMessage`, which the
|
|
6
|
+
* API verifies against; payload digests hash `canonicalJson`. Nothing here builds
|
|
7
|
+
* its own JSON.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.digestIdentityPayload = digestIdentityPayload;
|
|
11
|
+
exports.signIdentityProof = signIdentityProof;
|
|
12
|
+
require("./polyfill");
|
|
13
|
+
const sha256_1 = require("@noble/hashes/sha256");
|
|
14
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
15
|
+
const protocol_1 = require("@oxy.so/protocol");
|
|
16
|
+
const contracts_1 = require("@oxy.so/contracts");
|
|
17
|
+
/** SHA-256 hex of the canonical JSON of `payload` — the `payloadDigest` claim. */
|
|
18
|
+
function digestIdentityPayload(payload) {
|
|
19
|
+
return (0, utils_1.bytesToHex)((0, sha256_1.sha256)((0, utils_1.utf8ToBytes)((0, contracts_1.canonicalJson)(payload))));
|
|
20
|
+
}
|
|
21
|
+
/** Sign `claims` with the root, producing the proof as it travels. */
|
|
22
|
+
async function signIdentityProof(identity, claims) {
|
|
23
|
+
if (claims.rootPublicKey !== identity.publicKey.toLowerCase()) {
|
|
24
|
+
throw new Error('identity proof: the claims name a different root');
|
|
25
|
+
}
|
|
26
|
+
const signature = await (0, protocol_1.signMessage)((0, contracts_1.buildIdentityProofMessage)(claims), identity.privateKey);
|
|
27
|
+
return { v: contracts_1.IDENTITY_PROOF_VERSION, challenge: claims.challenge, expiresAt: claims.expiresAt, signature };
|
|
28
|
+
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Web identity
|
|
3
|
+
* Web identity holder — the crypto of a browser holding a personal root (ADR 0024).
|
|
4
4
|
*
|
|
5
|
-
* An Oxy
|
|
6
|
-
* secp256k1 key (`recoveryPhrase.ts`)
|
|
7
|
-
*
|
|
8
|
-
* being able to use it:
|
|
5
|
+
* An Oxy root is usually a BIP-39 mnemonic whose seed's first 32 bytes are the
|
|
6
|
+
* secp256k1 key (`recoveryPhrase.ts`), and for a few imported identities a raw
|
|
7
|
+
* private key with no phrase. Commons keeps it in the device keychain. This
|
|
8
|
+
* module lets a browser hold the SAME root without Oxy ever being able to use it:
|
|
9
9
|
*
|
|
10
|
-
|
|
10
|
+
secret ──AEAD(DEK)──▶ sealed secret (phrase entropy or raw key)
|
|
11
11
|
* DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
|
|
12
12
|
*
|
|
13
13
|
* The PRF output is produced inside the user's authenticator, behind user
|
|
14
14
|
* verification, and never leaves the page that asked for it. The envelope is
|
|
15
|
-
* therefore safe to store anywhere — the
|
|
16
|
-
*
|
|
15
|
+
* therefore safe to store anywhere — the holder host's IndexedDB and the server
|
|
16
|
+
* copy alike — and opens only with a registered passkey or the recovery material.
|
|
17
17
|
*
|
|
18
18
|
* PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
|
|
19
19
|
* CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
|
|
@@ -23,28 +23,30 @@
|
|
|
23
23
|
* Contract: `WebIdentityEnvelope` in `@oxy.so/contracts`.
|
|
24
24
|
*/
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.WebIdentityUnlockError = exports.WEB_IDENTITY_PRF_INPUT = void 0;
|
|
26
|
+
exports.WebIdentityUnlockError = exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH = exports.WEB_IDENTITY_PRF_INPUT = void 0;
|
|
27
27
|
exports.wipeBytes = wipeBytes;
|
|
28
|
+
exports.wipeOpenedIdentity = wipeOpenedIdentity;
|
|
29
|
+
exports.normalizeMnemonic = normalizeMnemonic;
|
|
28
30
|
exports.deriveIdentityFromMnemonic = deriveIdentityFromMnemonic;
|
|
31
|
+
exports.deriveIdentityFromPrivateKey = deriveIdentityFromPrivateKey;
|
|
32
|
+
exports.parseRecoveryMaterial = parseRecoveryMaterial;
|
|
33
|
+
exports.deriveIdentityFromRecoveryMaterial = deriveIdentityFromRecoveryMaterial;
|
|
29
34
|
exports.generateWebIdentity = generateWebIdentity;
|
|
30
35
|
exports.generateDataKey = generateDataKey;
|
|
36
|
+
exports.isUsablePrfOutput = isUsablePrfOutput;
|
|
31
37
|
exports.deriveKeyEncryptionKey = deriveKeyEncryptionKey;
|
|
32
|
-
exports.wrapDataKey = wrapDataKey;
|
|
33
38
|
exports.sealWebIdentity = sealWebIdentity;
|
|
34
39
|
exports.unwrapDataKey = unwrapDataKey;
|
|
35
40
|
exports.openWebIdentity = openWebIdentity;
|
|
36
41
|
exports.unlockWebIdentity = unlockWebIdentity;
|
|
37
42
|
exports.addWrap = addWrap;
|
|
43
|
+
exports.markWrapVerified = markWrapVerified;
|
|
38
44
|
exports.removeWrap = removeWrap;
|
|
39
|
-
exports.buildIdentityActionMessage = buildIdentityActionMessage;
|
|
40
|
-
exports.signIdentityAction = signIdentityAction;
|
|
41
|
-
exports.deriveTransferSas = deriveTransferSas;
|
|
42
45
|
require("./polyfill");
|
|
43
46
|
const bip39_1 = require("@scure/bip39");
|
|
44
47
|
const english_1 = require("@scure/bip39/wordlists/english");
|
|
45
48
|
const sha256_1 = require("@noble/hashes/sha256");
|
|
46
49
|
const utils_1 = require("@noble/hashes/utils");
|
|
47
|
-
const protocol_1 = require("@oxy.so/protocol");
|
|
48
50
|
const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
|
|
49
51
|
const contracts_1 = require("@oxy.so/contracts");
|
|
50
52
|
const aead_1 = require("./aead");
|
|
@@ -57,10 +59,10 @@ const kdf_1 = require("./kdf");
|
|
|
57
59
|
* scheme change bumps the label — and with it every KEK — instead of mutating it.
|
|
58
60
|
*/
|
|
59
61
|
exports.WEB_IDENTITY_PRF_INPUT = (0, sha256_1.sha256)((0, utils_1.utf8ToBytes)('oxy-web-identity-carrier/prf/v1'));
|
|
60
|
-
/** HKDF salt of the key-encryption-key schedule.
|
|
62
|
+
/** HKDF salt of the key-encryption-key schedule. Shared by envelope versions 1 and 2. */
|
|
61
63
|
const KEK_SALT = (0, utils_1.utf8ToBytes)('oxy-web-identity-carrier/kek/v1');
|
|
62
|
-
/** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). */
|
|
63
|
-
|
|
64
|
+
/** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). Nothing shorter or longer is usable. */
|
|
65
|
+
exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH = 32;
|
|
64
66
|
class WebIdentityUnlockError extends Error {
|
|
65
67
|
constructor(failure, message) {
|
|
66
68
|
super(message);
|
|
@@ -72,22 +74,34 @@ exports.WebIdentityUnlockError = WebIdentityUnlockError;
|
|
|
72
74
|
function aadFor(parts) {
|
|
73
75
|
return (0, utils_1.utf8ToBytes)(JSON.stringify(parts));
|
|
74
76
|
}
|
|
75
|
-
function
|
|
76
|
-
return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: '
|
|
77
|
+
function secretAad(publicKey, secretKind) {
|
|
78
|
+
return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'secret', secretKind, publicKey });
|
|
77
79
|
}
|
|
78
|
-
function wrapAad(publicKey, credentialId) {
|
|
79
|
-
return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId });
|
|
80
|
+
function wrapAad(publicKey, credentialId, rpId) {
|
|
81
|
+
return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId, rpId });
|
|
80
82
|
}
|
|
81
83
|
/** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
|
|
82
84
|
function wipeBytes(bytes) {
|
|
83
85
|
bytes.fill(0);
|
|
84
86
|
}
|
|
87
|
+
/** Best-effort removal of secret strings from an opened identity. */
|
|
88
|
+
function wipeOpenedIdentity(identity) {
|
|
89
|
+
identity.privateKey = '';
|
|
90
|
+
if (identity.kind === 'mnemonic')
|
|
91
|
+
identity.mnemonic = '';
|
|
92
|
+
}
|
|
93
|
+
/** Normalize a typed phrase: trimmed, lowercase, single spaces. */
|
|
94
|
+
function normalizeMnemonic(mnemonic) {
|
|
95
|
+
return mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
|
|
96
|
+
}
|
|
85
97
|
/**
|
|
86
98
|
* Derive the identity from a mnemonic exactly as Commons does
|
|
87
|
-
* (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
|
|
99
|
+
* (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1. Any standard
|
|
100
|
+
* BIP-39 length (12, 15, 18, 21, 24 words) is accepted; the derivation does not
|
|
101
|
+
* depend on the length.
|
|
88
102
|
*/
|
|
89
103
|
function deriveIdentityFromMnemonic(mnemonic) {
|
|
90
|
-
const normalized = mnemonic
|
|
104
|
+
const normalized = normalizeMnemonic(mnemonic);
|
|
91
105
|
if (!(0, bip39_1.validateMnemonic)(normalized, english_1.wordlist)) {
|
|
92
106
|
throw new Error('Invalid recovery phrase');
|
|
93
107
|
}
|
|
@@ -96,7 +110,41 @@ function deriveIdentityFromMnemonic(mnemonic) {
|
|
|
96
110
|
wipeBytes(seed);
|
|
97
111
|
const privateKey = (0, utils_1.bytesToHex)(privateKeyBytes);
|
|
98
112
|
wipeBytes(privateKeyBytes);
|
|
99
|
-
return { mnemonic: normalized, privateKey, publicKey: (0, secp256k1_1.deriveSecp256k1PublicKey)(privateKey) };
|
|
113
|
+
return { kind: 'mnemonic', mnemonic: normalized, privateKey, publicKey: (0, secp256k1_1.deriveSecp256k1PublicKey)(privateKey) };
|
|
114
|
+
}
|
|
115
|
+
/** A raw 32-byte private key (hex, optional `0x`) as an identity. Never gains a phrase. */
|
|
116
|
+
function deriveIdentityFromPrivateKey(privateKey) {
|
|
117
|
+
const normalized = privateKey.trim().toLowerCase().replace(/^0x/, '');
|
|
118
|
+
if (!/^[0-9a-f]{64}$/.test(normalized)) {
|
|
119
|
+
throw new Error('Invalid private key');
|
|
120
|
+
}
|
|
121
|
+
let publicKey;
|
|
122
|
+
try {
|
|
123
|
+
publicKey = (0, secp256k1_1.deriveSecp256k1PublicKey)(normalized);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new Error('Invalid private key');
|
|
127
|
+
}
|
|
128
|
+
return { kind: 'raw-key', mnemonic: null, privateKey: normalized, publicKey };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Read what a person typed as recovery material: a BIP-39 phrase, or a 64-hex
|
|
132
|
+
* private key. Throws `Invalid recovery material` for anything else.
|
|
133
|
+
*/
|
|
134
|
+
function parseRecoveryMaterial(input) {
|
|
135
|
+
const trimmed = input.trim();
|
|
136
|
+
if (/^(0x)?[0-9a-fA-F]{64}$/.test(trimmed))
|
|
137
|
+
return { kind: 'raw-key', privateKey: trimmed };
|
|
138
|
+
const words = normalizeMnemonic(trimmed);
|
|
139
|
+
if (words && (0, bip39_1.validateMnemonic)(words, english_1.wordlist))
|
|
140
|
+
return { kind: 'mnemonic', mnemonic: words };
|
|
141
|
+
throw new Error('Invalid recovery material');
|
|
142
|
+
}
|
|
143
|
+
/** Derive the identity recovery material names. */
|
|
144
|
+
function deriveIdentityFromRecoveryMaterial(material) {
|
|
145
|
+
return material.kind === 'mnemonic'
|
|
146
|
+
? deriveIdentityFromMnemonic(material.mnemonic)
|
|
147
|
+
: deriveIdentityFromPrivateKey(material.privateKey);
|
|
100
148
|
}
|
|
101
149
|
/** A brand-new 12-word identity. Nothing is persisted. */
|
|
102
150
|
function generateWebIdentity() {
|
|
@@ -108,29 +156,40 @@ function generateDataKey() {
|
|
|
108
156
|
globalThis.crypto.getRandomValues(key);
|
|
109
157
|
return key;
|
|
110
158
|
}
|
|
159
|
+
/** Whether `bytes` is a usable PRF output: exactly 32 bytes. */
|
|
160
|
+
function isUsablePrfOutput(bytes) {
|
|
161
|
+
return bytes instanceof Uint8Array && bytes.byteLength === exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH;
|
|
162
|
+
}
|
|
111
163
|
/** The key-encryption key a passkey's PRF output yields for its own wrap. */
|
|
112
164
|
function deriveKeyEncryptionKey(prfOutput, credentialId) {
|
|
113
|
-
if (prfOutput.length !==
|
|
114
|
-
throw new Error(`PRF output must be ${
|
|
165
|
+
if (prfOutput.length !== exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH) {
|
|
166
|
+
throw new Error(`PRF output must be ${exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
|
|
115
167
|
}
|
|
116
168
|
return (0, kdf_1.hkdfSha256)(prfOutput, KEK_SALT, (0, utils_1.utf8ToBytes)(credentialId), aead_1.AEAD_KEY_LENGTH);
|
|
117
169
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
170
|
+
function wrapFor(publicKey, dataKey, input, now) {
|
|
171
|
+
const kek = deriveKeyEncryptionKey(input.prfOutput, input.credentialId);
|
|
121
172
|
try {
|
|
122
|
-
const { nonce, ciphertext } = (0, aead_1.encryptAead)(kek, dataKey, wrapAad(publicKey, credentialId));
|
|
173
|
+
const { nonce, ciphertext } = (0, aead_1.encryptAead)(kek, dataKey, wrapAad(publicKey, input.credentialId, input.rpId));
|
|
123
174
|
return {
|
|
124
|
-
credentialId,
|
|
175
|
+
credentialId: input.credentialId,
|
|
125
176
|
nonce: (0, utils_1.bytesToHex)(nonce),
|
|
126
177
|
wrappedKey: (0, utils_1.bytesToHex)(ciphertext),
|
|
127
178
|
createdAt: now.toISOString(),
|
|
179
|
+
rpId: input.rpId,
|
|
180
|
+
...(input.verifiedAt ? { verifiedAt: input.verifiedAt } : {}),
|
|
128
181
|
};
|
|
129
182
|
}
|
|
130
183
|
finally {
|
|
131
184
|
wipeBytes(kek);
|
|
132
185
|
}
|
|
133
186
|
}
|
|
187
|
+
function secretOf(identity) {
|
|
188
|
+
if (identity.kind === 'mnemonic') {
|
|
189
|
+
return { kind: 'mnemonic-entropy', bytes: (0, bip39_1.mnemonicToEntropy)(identity.mnemonic, english_1.wordlist) };
|
|
190
|
+
}
|
|
191
|
+
return { kind: 'raw-private-key', bytes: (0, utils_1.hexToBytes)(identity.privateKey) };
|
|
192
|
+
}
|
|
134
193
|
/**
|
|
135
194
|
* Seal an identity into a new envelope that opens with the given passkey.
|
|
136
195
|
*
|
|
@@ -138,26 +197,34 @@ function wrapDataKey(dataKey, prfOutput, credentialId, publicKey, now = new Date
|
|
|
138
197
|
* session; wipe it when done.
|
|
139
198
|
*/
|
|
140
199
|
function sealWebIdentity(identity, firstWrap, now = new Date()) {
|
|
141
|
-
const derived = deriveIdentityFromMnemonic(identity.mnemonic);
|
|
142
|
-
|
|
143
|
-
|
|
200
|
+
const derived = identity.kind === 'raw-key' ? deriveIdentityFromPrivateKey(identity.privateKey) : deriveIdentityFromMnemonic(identity.mnemonic);
|
|
201
|
+
const publicKey = identity.publicKey.toLowerCase();
|
|
202
|
+
if (derived.publicKey !== publicKey) {
|
|
203
|
+
wipeOpenedIdentity(derived);
|
|
204
|
+
throw new Error('The recovery material does not belong to this identity');
|
|
144
205
|
}
|
|
206
|
+
const secret = secretOf(derived);
|
|
207
|
+
wipeOpenedIdentity(derived);
|
|
145
208
|
const dataKey = generateDataKey();
|
|
146
|
-
const entropy = (0, bip39_1.mnemonicToEntropy)(derived.mnemonic, english_1.wordlist);
|
|
147
209
|
try {
|
|
148
|
-
const { nonce, ciphertext } = (0, aead_1.encryptAead)(dataKey,
|
|
210
|
+
const { nonce, ciphertext } = (0, aead_1.encryptAead)(dataKey, secret.bytes, secretAad(publicKey, secret.kind));
|
|
149
211
|
const envelope = {
|
|
150
212
|
version: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION,
|
|
151
213
|
algorithm: 'xchacha20poly1305',
|
|
152
|
-
publicKey
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
214
|
+
publicKey,
|
|
215
|
+
secretKind: secret.kind,
|
|
216
|
+
secretNonce: (0, utils_1.bytesToHex)(nonce),
|
|
217
|
+
sealedSecret: (0, utils_1.bytesToHex)(ciphertext),
|
|
218
|
+
wraps: [wrapFor(publicKey, dataKey, firstWrap, now)],
|
|
156
219
|
};
|
|
157
220
|
return { envelope, dataKey };
|
|
158
221
|
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
wipeBytes(dataKey);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
159
226
|
finally {
|
|
160
|
-
wipeBytes(
|
|
227
|
+
wipeBytes(secret.bytes);
|
|
161
228
|
}
|
|
162
229
|
}
|
|
163
230
|
/** Recover the data key with one passkey's PRF output. */
|
|
@@ -168,7 +235,7 @@ function unwrapDataKey(envelope, prfOutput, credentialId) {
|
|
|
168
235
|
}
|
|
169
236
|
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
170
237
|
try {
|
|
171
|
-
return (0, aead_1.decryptAead)(kek, (0, utils_1.hexToBytes)(wrap.nonce), (0, utils_1.hexToBytes)(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId));
|
|
238
|
+
return (0, aead_1.decryptAead)(kek, (0, utils_1.hexToBytes)(wrap.nonce), (0, utils_1.hexToBytes)(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId, wrap.rpId));
|
|
172
239
|
}
|
|
173
240
|
catch {
|
|
174
241
|
throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
|
|
@@ -180,26 +247,38 @@ function unwrapDataKey(envelope, prfOutput, credentialId) {
|
|
|
180
247
|
/**
|
|
181
248
|
* Open an envelope with its data key.
|
|
182
249
|
*
|
|
183
|
-
* The derived public key is checked against the envelope's own: a sealed
|
|
250
|
+
* The derived public key is checked against the envelope's own: a sealed secret
|
|
184
251
|
* that decrypts into a different identity is treated as corruption, never used.
|
|
185
252
|
*/
|
|
186
253
|
function openWebIdentity(envelope, dataKey) {
|
|
187
|
-
|
|
254
|
+
const publicKey = envelope.publicKey.toLowerCase();
|
|
255
|
+
const kind = envelope.secretKind;
|
|
256
|
+
let secret;
|
|
188
257
|
try {
|
|
189
|
-
|
|
258
|
+
secret = (0, aead_1.decryptAead)(dataKey, (0, utils_1.hexToBytes)(envelope.secretNonce), (0, utils_1.hexToBytes)(envelope.sealedSecret), secretAad(envelope.publicKey, kind));
|
|
190
259
|
}
|
|
191
260
|
catch {
|
|
192
261
|
throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
|
|
193
262
|
}
|
|
194
263
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
264
|
+
let identity;
|
|
265
|
+
try {
|
|
266
|
+
identity =
|
|
267
|
+
kind === 'mnemonic-entropy'
|
|
268
|
+
? deriveIdentityFromMnemonic((0, bip39_1.entropyToMnemonic)(secret, english_1.wordlist))
|
|
269
|
+
: deriveIdentityFromPrivateKey((0, utils_1.bytesToHex)(secret));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
throw new WebIdentityUnlockError('corrupt', 'The sealed identity is malformed');
|
|
273
|
+
}
|
|
274
|
+
if (identity.publicKey !== publicKey) {
|
|
275
|
+
wipeOpenedIdentity(identity);
|
|
197
276
|
throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
|
|
198
277
|
}
|
|
199
278
|
return identity;
|
|
200
279
|
}
|
|
201
280
|
finally {
|
|
202
|
-
wipeBytes(
|
|
281
|
+
wipeBytes(secret);
|
|
203
282
|
}
|
|
204
283
|
}
|
|
205
284
|
/** Unwrap and open in one step. */
|
|
@@ -213,13 +292,27 @@ function unlockWebIdentity(envelope, prfOutput, credentialId) {
|
|
|
213
292
|
}
|
|
214
293
|
}
|
|
215
294
|
/** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
|
|
216
|
-
function addWrap(envelope, dataKey,
|
|
295
|
+
function addWrap(envelope, dataKey, wrapInput, now = new Date()) {
|
|
217
296
|
// Proves `dataKey` belongs to THIS envelope before anything new can open it.
|
|
218
|
-
openWebIdentity(envelope, dataKey);
|
|
219
|
-
const others = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
|
|
297
|
+
wipeOpenedIdentity(openWebIdentity(envelope, dataKey));
|
|
298
|
+
const others = envelope.wraps.filter((entry) => entry.credentialId !== wrapInput.credentialId);
|
|
299
|
+
return {
|
|
300
|
+
...envelope,
|
|
301
|
+
wraps: [...others, wrapFor(envelope.publicKey, dataKey, wrapInput, now)],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Record that a wrap's passkey opened the envelope in a ceremony of its own. The
|
|
306
|
+
* AEAD does not cover `verifiedAt`; it is holder metadata the owner writes with a
|
|
307
|
+
* root proof, not a cryptographic claim.
|
|
308
|
+
*/
|
|
309
|
+
function markWrapVerified(envelope, credentialId, now = new Date()) {
|
|
310
|
+
if (!envelope.wraps.some((entry) => entry.credentialId === credentialId)) {
|
|
311
|
+
throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
|
|
312
|
+
}
|
|
220
313
|
return {
|
|
221
314
|
...envelope,
|
|
222
|
-
wraps:
|
|
315
|
+
wraps: envelope.wraps.map((entry) => entry.credentialId === credentialId ? { ...entry, verifiedAt: now.toISOString() } : entry),
|
|
223
316
|
};
|
|
224
317
|
}
|
|
225
318
|
/** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
|
|
@@ -230,33 +323,3 @@ function removeWrap(envelope, credentialId) {
|
|
|
230
323
|
}
|
|
231
324
|
return { ...envelope, wraps: remaining };
|
|
232
325
|
}
|
|
233
|
-
/**
|
|
234
|
-
* The message an identity key signs to authorize an account action
|
|
235
|
-
* (`link_identity`, `web_envelope_delete`, …): `JSON.stringify({ action, userId,
|
|
236
|
-
* timestamp })`, byte-identical to what the API reconstructs.
|
|
237
|
-
*/
|
|
238
|
-
function buildIdentityActionMessage(action, userId, timestamp) {
|
|
239
|
-
return JSON.stringify({ action, userId, timestamp });
|
|
240
|
-
}
|
|
241
|
-
/** Sign an identity-action message with an opened identity's key. */
|
|
242
|
-
async function signIdentityAction(identity, action, userId, timestamp = Date.now()) {
|
|
243
|
-
const signature = await (0, protocol_1.signMessage)(buildIdentityActionMessage(action, userId, timestamp), identity.privateKey);
|
|
244
|
-
return { signature, timestamp };
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* The 6-digit short authentication string both sides of an identity transfer show.
|
|
248
|
-
*
|
|
249
|
-
* Bound to the pairing and to BOTH ephemeral public keys in their roles, so a relay
|
|
250
|
-
* that substitutes either key produces a different code on each screen. The user
|
|
251
|
-
* comparing the two codes is what makes the transfer safe against the relay itself.
|
|
252
|
-
*/
|
|
253
|
-
function deriveTransferSas(input) {
|
|
254
|
-
const digest = (0, sha256_1.sha256)((0, utils_1.utf8ToBytes)(JSON.stringify({
|
|
255
|
-
v: 'oxy-identity-transfer-sas-v1',
|
|
256
|
-
pairingId: input.pairingId.toLowerCase(),
|
|
257
|
-
initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
|
|
258
|
-
responder: input.responderEphemeralPublicKey.toLowerCase(),
|
|
259
|
-
})));
|
|
260
|
-
const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
|
|
261
|
-
return String(value % 1000000).padStart(6, '0');
|
|
262
|
-
}
|