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