@oxy.so/core 1.2.0 → 1.3.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/crypto/identityMove.js +123 -0
- package/dist/cjs/crypto/keyManager.js +60 -21
- package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
- package/dist/cjs/i18n/locales/ar-SA.json +16 -13
- package/dist/cjs/i18n/locales/ca-ES.json +16 -13
- package/dist/cjs/i18n/locales/de-DE.json +16 -13
- package/dist/cjs/i18n/locales/en-US.json +16 -17
- package/dist/cjs/i18n/locales/es-ES.json +16 -17
- package/dist/cjs/i18n/locales/fr-FR.json +16 -13
- package/dist/cjs/i18n/locales/it-IT.json +16 -13
- package/dist/cjs/i18n/locales/ja-JP.json +16 -13
- package/dist/cjs/i18n/locales/ko-KR.json +16 -13
- package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
- package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
- package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
- package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
- package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
- package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
- package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
- package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
- package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
- package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
- package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
- package/dist/cjs/i18n/locales/pt-PT.json +16 -13
- package/dist/cjs/i18n/locales/zh-CN.json +16 -13
- package/dist/cjs/index.js +38 -4
- package/dist/cjs/mixins/OxyServices.auth.js +14 -4
- package/dist/cjs/mixins/OxyServices.identity.js +14 -0
- package/dist/cjs/session/accountDialogController.js +333 -142
- package/dist/cjs/utils/authWebUrl.js +8 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/identityMove.js +110 -0
- package/dist/esm/crypto/keyManager.js +60 -21
- package/dist/esm/crypto/webIdentityCarrier.js +243 -0
- package/dist/esm/i18n/locales/ar-SA.json +16 -13
- package/dist/esm/i18n/locales/ca-ES.json +16 -13
- package/dist/esm/i18n/locales/de-DE.json +16 -13
- package/dist/esm/i18n/locales/en-US.json +16 -17
- package/dist/esm/i18n/locales/es-ES.json +16 -17
- package/dist/esm/i18n/locales/fr-FR.json +16 -13
- package/dist/esm/i18n/locales/it-IT.json +16 -13
- package/dist/esm/i18n/locales/ja-JP.json +16 -13
- package/dist/esm/i18n/locales/ko-KR.json +16 -13
- package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
- package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
- package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
- package/dist/esm/i18n/locales/locales/en-US.json +16 -17
- package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
- package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
- package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
- package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
- package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
- package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
- package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
- package/dist/esm/i18n/locales/pt-PT.json +16 -13
- package/dist/esm/i18n/locales/zh-CN.json +16 -13
- package/dist/esm/index.js +5 -1
- package/dist/esm/mixins/OxyServices.auth.js +14 -4
- package/dist/esm/mixins/OxyServices.identity.js +14 -0
- package/dist/esm/session/accountDialogController.js +334 -143
- package/dist/esm/utils/authWebUrl.js +7 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/identityMove.d.ts +59 -0
- package/dist/types/crypto/keyManager.d.ts +35 -5
- package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
- package/dist/types/models/interfaces.d.ts +15 -0
- package/dist/types/session/accountDialogController.d.ts +169 -17
- package/dist/types/utils/authWebUrl.d.ts +7 -0
- package/package.json +13 -13
- package/src/crypto/__tests__/identityMove.test.ts +104 -0
- package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
- package/src/crypto/identityMove.ts +133 -0
- package/src/crypto/keyManager.ts +71 -21
- package/src/crypto/webIdentityCarrier.ts +326 -0
- package/src/i18n/locales/ar-SA.json +16 -13
- package/src/i18n/locales/ca-ES.json +16 -13
- package/src/i18n/locales/de-DE.json +16 -13
- package/src/i18n/locales/en-US.json +16 -17
- package/src/i18n/locales/es-ES.json +16 -17
- package/src/i18n/locales/fr-FR.json +16 -13
- package/src/i18n/locales/it-IT.json +16 -13
- package/src/i18n/locales/ja-JP.json +16 -13
- package/src/i18n/locales/ko-KR.json +16 -13
- package/src/i18n/locales/pt-PT.json +16 -13
- package/src/i18n/locales/zh-CN.json +16 -13
- package/src/index.ts +38 -1
- package/src/mixins/OxyServices.auth.ts +16 -3
- package/src/mixins/OxyServices.identity.ts +14 -0
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
- package/src/models/interfaces.ts +15 -0
- package/src/session/__tests__/accountDialogController.test.ts +407 -10
- package/src/session/accountDialogController.ts +406 -145
- package/src/utils/__tests__/oauthPkce.test.ts +50 -26
- package/src/utils/authWebUrl.ts +8 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Identity move — the crypto of taking a web identity into Commons.
|
|
4
|
+
*
|
|
5
|
+
* Both carriers run this: `id.oxy.so` (the initiator, holding the identity) and
|
|
6
|
+
* Commons (the responder, receiving it). The relay in between sees two ephemeral
|
|
7
|
+
* public keys and ciphertext; the 6-digit SAS both screens show is what makes a
|
|
8
|
+
* relay that swapped a key visible to the person.
|
|
9
|
+
*
|
|
10
|
+
* PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.IDENTITY_MOVE_ACTIONS = void 0;
|
|
14
|
+
exports.generateMoveEphemeralKeyPair = generateMoveEphemeralKeyPair;
|
|
15
|
+
exports.deriveMoveKey = deriveMoveKey;
|
|
16
|
+
exports.deriveMoveSas = deriveMoveSas;
|
|
17
|
+
exports.sealIdentityForMove = sealIdentityForMove;
|
|
18
|
+
exports.openMovedIdentity = openMovedIdentity;
|
|
19
|
+
exports.buildMoveMessage = buildMoveMessage;
|
|
20
|
+
exports.signMoveAction = signMoveAction;
|
|
21
|
+
exports.verifyMoveReceipt = verifyMoveReceipt;
|
|
22
|
+
exports.buildMoveQrPayload = buildMoveQrPayload;
|
|
23
|
+
exports.parseMoveQrPayload = parseMoveQrPayload;
|
|
24
|
+
require("./polyfill");
|
|
25
|
+
const bip39_1 = require("@scure/bip39");
|
|
26
|
+
const english_1 = require("@scure/bip39/wordlists/english");
|
|
27
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
28
|
+
const protocol_1 = require("@oxy.so/protocol");
|
|
29
|
+
const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
|
|
30
|
+
const contracts_1 = require("@oxy.so/contracts");
|
|
31
|
+
const aead_1 = require("./aead");
|
|
32
|
+
const ecdh_1 = require("./ecdh");
|
|
33
|
+
const kdf_1 = require("./kdf");
|
|
34
|
+
const webIdentityCarrier_1 = require("./webIdentityCarrier");
|
|
35
|
+
const MOVE_KDF_INFO = (0, utils_1.utf8ToBytes)('oxy-identity-move-v1');
|
|
36
|
+
/** Actions the identity key signs during a move. */
|
|
37
|
+
exports.IDENTITY_MOVE_ACTIONS = {
|
|
38
|
+
seal: 'identity_move_seal',
|
|
39
|
+
received: 'identity_move_received',
|
|
40
|
+
};
|
|
41
|
+
/** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
|
|
42
|
+
function generateMoveEphemeralKeyPair() {
|
|
43
|
+
return (0, secp256k1_1.generateSecp256k1KeyPair)();
|
|
44
|
+
}
|
|
45
|
+
/** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
|
|
46
|
+
function deriveMoveKey(ownEphemeralPrivateKey, otherEphemeralPublicKey, moveId) {
|
|
47
|
+
const shared = (0, ecdh_1.deriveSharedSecret)(ownEphemeralPrivateKey, otherEphemeralPublicKey);
|
|
48
|
+
try {
|
|
49
|
+
return (0, kdf_1.hkdfSha256)(shared, (0, utils_1.utf8ToBytes)(moveId.toLowerCase()), MOVE_KDF_INFO, aead_1.AEAD_KEY_LENGTH);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
(0, webIdentityCarrier_1.wipeBytes)(shared);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The 6-digit code both screens show, bound to the move and both ephemeral keys in their roles. */
|
|
56
|
+
function deriveMoveSas(moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey) {
|
|
57
|
+
return (0, webIdentityCarrier_1.deriveTransferSas)({ pairingId: moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey });
|
|
58
|
+
}
|
|
59
|
+
function moveAad(moveId, publicKey) {
|
|
60
|
+
return (0, utils_1.utf8ToBytes)(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
|
|
61
|
+
}
|
|
62
|
+
/** Initiator: seal the identity's entropy for the responder. */
|
|
63
|
+
function sealIdentityForMove(identity, moveKey, moveId) {
|
|
64
|
+
const entropy = (0, bip39_1.mnemonicToEntropy)(identity.mnemonic, english_1.wordlist);
|
|
65
|
+
try {
|
|
66
|
+
const { nonce, ciphertext } = (0, aead_1.encryptAead)(moveKey, entropy, moveAad(moveId, identity.publicKey));
|
|
67
|
+
return { nonce: (0, utils_1.bytesToHex)(nonce), ciphertext: (0, utils_1.bytesToHex)(ciphertext) };
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
(0, webIdentityCarrier_1.wipeBytes)(entropy);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Responder: open the sealed identity. The derived public key must be the one
|
|
75
|
+
* the move declared — anything else is refused, never imported.
|
|
76
|
+
*/
|
|
77
|
+
function openMovedIdentity(sealed, moveKey, moveId, expectedPublicKey) {
|
|
78
|
+
let entropy;
|
|
79
|
+
try {
|
|
80
|
+
entropy = (0, aead_1.decryptAead)(moveKey, (0, utils_1.hexToBytes)(sealed.nonce), (0, utils_1.hexToBytes)(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error('The identity could not be opened. The codes may not have matched — start again.');
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const identity = (0, webIdentityCarrier_1.deriveIdentityFromMnemonic)((0, bip39_1.entropyToMnemonic)(entropy, english_1.wordlist));
|
|
87
|
+
if (identity.publicKey !== expectedPublicKey.toLowerCase()) {
|
|
88
|
+
throw new Error('The received identity is not the one being moved');
|
|
89
|
+
}
|
|
90
|
+
return identity;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
(0, webIdentityCarrier_1.wipeBytes)(entropy);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
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
|
+
/** The QR payload for a move. */
|
|
113
|
+
function buildMoveQrPayload(moveId) {
|
|
114
|
+
return `${contracts_1.IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
|
|
115
|
+
}
|
|
116
|
+
/** The move id in a scanned payload, or `null` when it is not a move QR. */
|
|
117
|
+
function parseMoveQrPayload(raw) {
|
|
118
|
+
const value = raw.trim();
|
|
119
|
+
if (!value.toLowerCase().startsWith(contracts_1.IDENTITY_MOVE_QR_PREFIX))
|
|
120
|
+
return null;
|
|
121
|
+
const id = value.slice(contracts_1.IDENTITY_MOVE_QR_PREFIX.length).toLowerCase();
|
|
122
|
+
return /^[0-9a-f]{32}$/.test(id) ? id : null;
|
|
123
|
+
}
|
|
@@ -752,6 +752,42 @@ class KeyManager {
|
|
|
752
752
|
return null;
|
|
753
753
|
}
|
|
754
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* Which identity key this device is actually acting as, per slot.
|
|
757
|
+
*
|
|
758
|
+
* There are two slots and they can disagree. The shared slot is what every
|
|
759
|
+
* cross-app reader takes first — `deriveScopedSeed` (so: the FairCoin wallet
|
|
760
|
+
* Peable derives) and `getSharedPrivateKey` — while the primary slot is what
|
|
761
|
+
* signing and the server-facing identity use. A disagreement is invisible to
|
|
762
|
+
* both sides on its own, and it is money: a wallet derived from a stale
|
|
763
|
+
* shared key watches addresses that the key published in the DID will never
|
|
764
|
+
* produce, so payments land where the recipient cannot see them.
|
|
765
|
+
*
|
|
766
|
+
* `activePublicKey` is the one money derives from — the same `shared ?? primary`
|
|
767
|
+
* order `deriveScopedSeed` uses, so a caller can compare it against the key
|
|
768
|
+
* published for the account and refuse to receive rather than watch the wrong
|
|
769
|
+
* addresses.
|
|
770
|
+
*
|
|
771
|
+
* Public keys are canonicalized, so two encodings of the same key compare
|
|
772
|
+
* equal. On web both slots are null and `inSync` is true: nothing is stored,
|
|
773
|
+
* so nothing can disagree.
|
|
774
|
+
*/
|
|
775
|
+
static async getIdentityKeyState() {
|
|
776
|
+
const [primaryRaw, sharedRaw] = await Promise.all([
|
|
777
|
+
KeyManager.getPublicKey(),
|
|
778
|
+
KeyManager.getSharedPublicKey(),
|
|
779
|
+
]);
|
|
780
|
+
const primaryPublicKey = primaryRaw ? KeyManager.canonicalPublicKey(primaryRaw) : null;
|
|
781
|
+
const sharedPublicKey = sharedRaw ? KeyManager.canonicalPublicKey(sharedRaw) : null;
|
|
782
|
+
return {
|
|
783
|
+
primaryPublicKey,
|
|
784
|
+
sharedPublicKey,
|
|
785
|
+
activePublicKey: sharedPublicKey ?? primaryPublicKey,
|
|
786
|
+
// Only a real disagreement is out of sync. A device with just one slot
|
|
787
|
+
// populated has nothing to contradict.
|
|
788
|
+
inSync: primaryPublicKey === null || sharedPublicKey === null || primaryPublicKey === sharedPublicKey,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
755
791
|
/**
|
|
756
792
|
* Check if a shared identity exists (accessible across all Oxy apps)
|
|
757
793
|
*
|
|
@@ -945,49 +981,52 @@ class KeyManager {
|
|
|
945
981
|
}
|
|
946
982
|
}
|
|
947
983
|
/**
|
|
948
|
-
*
|
|
984
|
+
* Make the shared slot hold THIS device's identity key.
|
|
949
985
|
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
986
|
+
* The shared slot is the one every Oxy app on the device reads, so it has to
|
|
987
|
+
* agree with the primary slot. This creates it when it is empty (the original
|
|
988
|
+
* migration, for apps that predate the shared identity) and repairs it when it
|
|
989
|
+
* holds a different key.
|
|
953
990
|
*
|
|
954
|
-
* @returns True
|
|
991
|
+
* @returns True when the shared slot ends up holding the device's key, false
|
|
992
|
+
* when there is no identity to share.
|
|
955
993
|
*/
|
|
956
|
-
static async
|
|
994
|
+
static async syncSharedIdentity() {
|
|
957
995
|
if (isWebPlatform()) {
|
|
958
996
|
return false;
|
|
959
997
|
}
|
|
960
998
|
try {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
if (hasShared) {
|
|
964
|
-
if ((0, logger_1.isDev)()) {
|
|
965
|
-
logger_1.logger.debug('Shared identity already exists, skipping migration', { component: 'KeyManager' });
|
|
966
|
-
}
|
|
999
|
+
const state = await KeyManager.getIdentityKeyState();
|
|
1000
|
+
if (state.sharedPublicKey !== null && (state.inSync || state.primaryPublicKey === null)) {
|
|
967
1001
|
return true;
|
|
968
1002
|
}
|
|
969
|
-
// Get local identity
|
|
970
1003
|
const privateKey = await KeyManager.getPrivateKey();
|
|
971
1004
|
if (!privateKey) {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
return false;
|
|
1005
|
+
// Nothing of this device's own to share. A shared slot that is already
|
|
1006
|
+
// populated stays as it is: it is the only identity here.
|
|
1007
|
+
return state.sharedPublicKey !== null;
|
|
976
1008
|
}
|
|
977
|
-
// Import to shared storage
|
|
978
1009
|
await KeyManager.importSharedIdentity(privateKey);
|
|
979
|
-
|
|
980
|
-
|
|
1010
|
+
// A slot that held a DIFFERENT key is the dangerous case, so it is said
|
|
1011
|
+
// out loud rather than debug-logged: until this write, cross-app readers
|
|
1012
|
+
// (`deriveScopedSeed`, so Peable's wallet) were deriving from one key
|
|
1013
|
+
// while signing and the server used another.
|
|
1014
|
+
if (state.sharedPublicKey !== null) {
|
|
1015
|
+
logger_1.logger.warn('Shared identity held a different key than this device: repaired it from the primary slot.', { component: 'KeyManager', method: 'syncSharedIdentity' });
|
|
981
1016
|
}
|
|
982
1017
|
return true;
|
|
983
1018
|
}
|
|
984
1019
|
catch (error) {
|
|
985
1020
|
if ((0, logger_1.isDev)()) {
|
|
986
|
-
logger_1.logger.error('Failed to
|
|
1021
|
+
logger_1.logger.error('Failed to sync the shared identity', error, { component: 'KeyManager' });
|
|
987
1022
|
}
|
|
988
1023
|
return false;
|
|
989
1024
|
}
|
|
990
1025
|
}
|
|
1026
|
+
/** @deprecated Renamed to {@link KeyManager.syncSharedIdentity}. */
|
|
1027
|
+
static async migrateToSharedIdentity() {
|
|
1028
|
+
return KeyManager.syncSharedIdentity();
|
|
1029
|
+
}
|
|
991
1030
|
// ==================== END SHARED IDENTITY METHODS ====================
|
|
992
1031
|
/**
|
|
993
1032
|
* Atomically persist a key pair to secure storage with verification + backup.
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Web identity carrier — the crypto of "one identity, two carriers".
|
|
4
|
+
*
|
|
5
|
+
* An Oxy identity is a BIP-39 mnemonic; its seed's first 32 bytes are the
|
|
6
|
+
* secp256k1 key (`recoveryPhrase.ts`). Commons keeps that key in the device
|
|
7
|
+
* keychain. This module lets a browser carry the SAME identity without Oxy ever
|
|
8
|
+
* being able to use it:
|
|
9
|
+
*
|
|
10
|
+
* entropy ──AEAD(DEK)──▶ sealedEntropy
|
|
11
|
+
* DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
|
|
12
|
+
*
|
|
13
|
+
* The PRF output is produced inside the user's authenticator, behind user
|
|
14
|
+
* verification, and never leaves the page that asked for it. The envelope is
|
|
15
|
+
* therefore safe to store anywhere — the identity origin's IndexedDB and the
|
|
16
|
+
* server copy alike — and opens only with a registered passkey or the phrase.
|
|
17
|
+
*
|
|
18
|
+
* PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
|
|
19
|
+
* CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
|
|
20
|
+
* PRF output in; everything here is deterministic given its inputs, so it is
|
|
21
|
+
* identical on web, Node and React Native, and fully unit-testable.
|
|
22
|
+
*
|
|
23
|
+
* Contract: `WebIdentityEnvelope` in `@oxy.so/contracts`.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.WebIdentityUnlockError = exports.WEB_IDENTITY_PRF_INPUT = void 0;
|
|
27
|
+
exports.wipeBytes = wipeBytes;
|
|
28
|
+
exports.deriveIdentityFromMnemonic = deriveIdentityFromMnemonic;
|
|
29
|
+
exports.generateWebIdentity = generateWebIdentity;
|
|
30
|
+
exports.generateDataKey = generateDataKey;
|
|
31
|
+
exports.deriveKeyEncryptionKey = deriveKeyEncryptionKey;
|
|
32
|
+
exports.wrapDataKey = wrapDataKey;
|
|
33
|
+
exports.sealWebIdentity = sealWebIdentity;
|
|
34
|
+
exports.unwrapDataKey = unwrapDataKey;
|
|
35
|
+
exports.openWebIdentity = openWebIdentity;
|
|
36
|
+
exports.unlockWebIdentity = unlockWebIdentity;
|
|
37
|
+
exports.addWrap = addWrap;
|
|
38
|
+
exports.removeWrap = removeWrap;
|
|
39
|
+
exports.buildIdentityActionMessage = buildIdentityActionMessage;
|
|
40
|
+
exports.signIdentityAction = signIdentityAction;
|
|
41
|
+
exports.deriveTransferSas = deriveTransferSas;
|
|
42
|
+
require("./polyfill");
|
|
43
|
+
const bip39_1 = require("@scure/bip39");
|
|
44
|
+
const english_1 = require("@scure/bip39/wordlists/english");
|
|
45
|
+
const sha256_1 = require("@noble/hashes/sha256");
|
|
46
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
47
|
+
const protocol_1 = require("@oxy.so/protocol");
|
|
48
|
+
const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
|
|
49
|
+
const contracts_1 = require("@oxy.so/contracts");
|
|
50
|
+
const aead_1 = require("./aead");
|
|
51
|
+
const kdf_1 = require("./kdf");
|
|
52
|
+
/**
|
|
53
|
+
* The PRF input every identity-carrier ceremony evaluates (`prf.eval.first`).
|
|
54
|
+
*
|
|
55
|
+
* Fixed rather than per-wrap: discoverable sign-in has no credential list to key
|
|
56
|
+
* `evalByCredential` by, and each passkey's output is already distinct. A future
|
|
57
|
+
* scheme change bumps the label — and with it every KEK — instead of mutating it.
|
|
58
|
+
*/
|
|
59
|
+
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. Versioned with the envelope. */
|
|
61
|
+
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
|
+
const PRF_OUTPUT_LENGTH = 32;
|
|
64
|
+
class WebIdentityUnlockError extends Error {
|
|
65
|
+
constructor(failure, message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.failure = failure;
|
|
68
|
+
this.name = 'WebIdentityUnlockError';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
exports.WebIdentityUnlockError = WebIdentityUnlockError;
|
|
72
|
+
function aadFor(parts) {
|
|
73
|
+
return (0, utils_1.utf8ToBytes)(JSON.stringify(parts));
|
|
74
|
+
}
|
|
75
|
+
function entropyAad(publicKey) {
|
|
76
|
+
return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'entropy', publicKey });
|
|
77
|
+
}
|
|
78
|
+
function wrapAad(publicKey, credentialId) {
|
|
79
|
+
return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId });
|
|
80
|
+
}
|
|
81
|
+
/** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
|
|
82
|
+
function wipeBytes(bytes) {
|
|
83
|
+
bytes.fill(0);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Derive the identity from a mnemonic exactly as Commons does
|
|
87
|
+
* (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
|
|
88
|
+
*/
|
|
89
|
+
function deriveIdentityFromMnemonic(mnemonic) {
|
|
90
|
+
const normalized = mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
|
|
91
|
+
if (!(0, bip39_1.validateMnemonic)(normalized, english_1.wordlist)) {
|
|
92
|
+
throw new Error('Invalid recovery phrase');
|
|
93
|
+
}
|
|
94
|
+
const seed = (0, bip39_1.mnemonicToSeedSync)(normalized);
|
|
95
|
+
const privateKeyBytes = seed.slice(0, 32);
|
|
96
|
+
wipeBytes(seed);
|
|
97
|
+
const privateKey = (0, utils_1.bytesToHex)(privateKeyBytes);
|
|
98
|
+
wipeBytes(privateKeyBytes);
|
|
99
|
+
return { mnemonic: normalized, privateKey, publicKey: (0, secp256k1_1.deriveSecp256k1PublicKey)(privateKey) };
|
|
100
|
+
}
|
|
101
|
+
/** A brand-new 12-word identity. Nothing is persisted. */
|
|
102
|
+
function generateWebIdentity() {
|
|
103
|
+
return deriveIdentityFromMnemonic((0, bip39_1.generateMnemonic)(english_1.wordlist, 128));
|
|
104
|
+
}
|
|
105
|
+
/** A fresh random data key for a new envelope. */
|
|
106
|
+
function generateDataKey() {
|
|
107
|
+
const key = new Uint8Array(aead_1.AEAD_KEY_LENGTH);
|
|
108
|
+
globalThis.crypto.getRandomValues(key);
|
|
109
|
+
return key;
|
|
110
|
+
}
|
|
111
|
+
/** The key-encryption key a passkey's PRF output yields for its own wrap. */
|
|
112
|
+
function deriveKeyEncryptionKey(prfOutput, credentialId) {
|
|
113
|
+
if (prfOutput.length !== PRF_OUTPUT_LENGTH) {
|
|
114
|
+
throw new Error(`PRF output must be ${PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
|
|
115
|
+
}
|
|
116
|
+
return (0, kdf_1.hkdfSha256)(prfOutput, KEK_SALT, (0, utils_1.utf8ToBytes)(credentialId), aead_1.AEAD_KEY_LENGTH);
|
|
117
|
+
}
|
|
118
|
+
/** Wrap `dataKey` for one passkey. */
|
|
119
|
+
function wrapDataKey(dataKey, prfOutput, credentialId, publicKey, now = new Date()) {
|
|
120
|
+
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
121
|
+
try {
|
|
122
|
+
const { nonce, ciphertext } = (0, aead_1.encryptAead)(kek, dataKey, wrapAad(publicKey, credentialId));
|
|
123
|
+
return {
|
|
124
|
+
credentialId,
|
|
125
|
+
nonce: (0, utils_1.bytesToHex)(nonce),
|
|
126
|
+
wrappedKey: (0, utils_1.bytesToHex)(ciphertext),
|
|
127
|
+
createdAt: now.toISOString(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
wipeBytes(kek);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Seal an identity into a new envelope that opens with the given passkey.
|
|
136
|
+
*
|
|
137
|
+
* Returns the data key too, so the caller can add further wraps in the same
|
|
138
|
+
* session; wipe it when done.
|
|
139
|
+
*/
|
|
140
|
+
function sealWebIdentity(identity, firstWrap, now = new Date()) {
|
|
141
|
+
const derived = deriveIdentityFromMnemonic(identity.mnemonic);
|
|
142
|
+
if (derived.publicKey !== identity.publicKey.toLowerCase()) {
|
|
143
|
+
throw new Error('The recovery phrase does not belong to this identity');
|
|
144
|
+
}
|
|
145
|
+
const dataKey = generateDataKey();
|
|
146
|
+
const entropy = (0, bip39_1.mnemonicToEntropy)(derived.mnemonic, english_1.wordlist);
|
|
147
|
+
try {
|
|
148
|
+
const { nonce, ciphertext } = (0, aead_1.encryptAead)(dataKey, entropy, entropyAad(derived.publicKey));
|
|
149
|
+
const envelope = {
|
|
150
|
+
version: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION,
|
|
151
|
+
algorithm: 'xchacha20poly1305',
|
|
152
|
+
publicKey: derived.publicKey,
|
|
153
|
+
entropyNonce: (0, utils_1.bytesToHex)(nonce),
|
|
154
|
+
sealedEntropy: (0, utils_1.bytesToHex)(ciphertext),
|
|
155
|
+
wraps: [wrapDataKey(dataKey, firstWrap.prfOutput, firstWrap.credentialId, derived.publicKey, now)],
|
|
156
|
+
};
|
|
157
|
+
return { envelope, dataKey };
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
wipeBytes(entropy);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Recover the data key with one passkey's PRF output. */
|
|
164
|
+
function unwrapDataKey(envelope, prfOutput, credentialId) {
|
|
165
|
+
const wrap = envelope.wraps.find((entry) => entry.credentialId === credentialId);
|
|
166
|
+
if (!wrap) {
|
|
167
|
+
throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
|
|
168
|
+
}
|
|
169
|
+
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
170
|
+
try {
|
|
171
|
+
return (0, aead_1.decryptAead)(kek, (0, utils_1.hexToBytes)(wrap.nonce), (0, utils_1.hexToBytes)(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
wipeBytes(kek);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Open an envelope with its data key.
|
|
182
|
+
*
|
|
183
|
+
* The derived public key is checked against the envelope's own: a sealed entropy
|
|
184
|
+
* that decrypts into a different identity is treated as corruption, never used.
|
|
185
|
+
*/
|
|
186
|
+
function openWebIdentity(envelope, dataKey) {
|
|
187
|
+
let entropy;
|
|
188
|
+
try {
|
|
189
|
+
entropy = (0, aead_1.decryptAead)(dataKey, (0, utils_1.hexToBytes)(envelope.entropyNonce), (0, utils_1.hexToBytes)(envelope.sealedEntropy), entropyAad(envelope.publicKey));
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const identity = deriveIdentityFromMnemonic((0, bip39_1.entropyToMnemonic)(entropy, english_1.wordlist));
|
|
196
|
+
if (identity.publicKey !== envelope.publicKey.toLowerCase()) {
|
|
197
|
+
throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
|
|
198
|
+
}
|
|
199
|
+
return identity;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
wipeBytes(entropy);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Unwrap and open in one step. */
|
|
206
|
+
function unlockWebIdentity(envelope, prfOutput, credentialId) {
|
|
207
|
+
const dataKey = unwrapDataKey(envelope, prfOutput, credentialId);
|
|
208
|
+
try {
|
|
209
|
+
return openWebIdentity(envelope, dataKey);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
wipeBytes(dataKey);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
|
|
216
|
+
function addWrap(envelope, dataKey, prfOutput, credentialId, now = new Date()) {
|
|
217
|
+
// 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);
|
|
220
|
+
return {
|
|
221
|
+
...envelope,
|
|
222
|
+
wraps: [...others, wrapDataKey(dataKey, prfOutput, credentialId, envelope.publicKey, now)],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
|
|
226
|
+
function removeWrap(envelope, credentialId) {
|
|
227
|
+
const remaining = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
|
|
228
|
+
if (remaining.length === 0) {
|
|
229
|
+
throw new Error('An identity envelope must keep at least one passkey');
|
|
230
|
+
}
|
|
231
|
+
return { ...envelope, wraps: remaining };
|
|
232
|
+
}
|
|
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
|
+
}
|
|
@@ -52,19 +52,9 @@
|
|
|
52
52
|
"signup": {
|
|
53
53
|
"title": "أنشئ حسابك",
|
|
54
54
|
"subtitle": "هوية واحدة لكل النظام البيئي.",
|
|
55
|
-
"commonsHeadline": "أنشئ هويتك في Commons",
|
|
56
|
-
"commonsExplainer": "Commons هي هوية Oxy الخاصة بك بحفظ ذاتي كامل — تطبيق واحد يعمل في كل مكان.",
|
|
57
55
|
"createInCommons": "أنشئ هويتك في Commons",
|
|
58
|
-
"passkeyAlternative": "لا تملك Commons؟ أنشئ مفتاح مرور على هذا الجهاز بدلاً من ذلك",
|
|
59
56
|
"createAccount": "إنشاء حساب",
|
|
60
|
-
"creating": "جارٍ الإنشاء…",
|
|
61
|
-
"usernamePlaceholder": "اختر اسم مستخدم",
|
|
62
|
-
"usernameChecking": "جارٍ التحقق…",
|
|
63
|
-
"usernameAvailable": "متاح",
|
|
64
|
-
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل",
|
|
65
57
|
"backToSignInLink": "لديك حساب بالفعل؟ سجّل الدخول",
|
|
66
|
-
"hubExplainer": "أنشئ هوية Oxy الخاصة بك في نافذة آمنة، ثم عد إلى هنا مباشرة.",
|
|
67
|
-
"continueInWindow": "المتابعة في نافذة جديدة",
|
|
68
58
|
"welcome": {
|
|
69
59
|
"title": "مرحبًا بك في Oxy!",
|
|
70
60
|
"subtitle": "أنشئ حسابك في خطوات قليلة",
|
|
@@ -119,7 +109,6 @@
|
|
|
119
109
|
"qrHeadline": "سجّل الدخول بهوية Oxy الخاصة بك",
|
|
120
110
|
"signInWithOxy": "تسجيل الدخول باستخدام Oxy",
|
|
121
111
|
"useIdentityOnDevice": "لا تملك Commons؟ استخدم مفتاح مرور على هذا الجهاز بدلاً من ذلك",
|
|
122
|
-
"passkeySigningIn": "جارٍ تسجيل الدخول…",
|
|
123
112
|
"getCommons": "احصل على Commons",
|
|
124
113
|
"commonsNotInstalled": "لا تملك Commons؟ احصل على التطبيق لتسجيل الدخول بمعرّف Oxy الخاص بك.",
|
|
125
114
|
"showQrAnyway": "لدي Commons على جهاز آخر",
|
|
@@ -136,7 +125,20 @@
|
|
|
136
125
|
"openedInCommons": "تم الفتح في Commons",
|
|
137
126
|
"confirming": "جارٍ تأكيد الهوية",
|
|
138
127
|
"confirmed": "تم تأكيد الهوية"
|
|
139
|
-
}
|
|
128
|
+
},
|
|
129
|
+
"signInFailures": {
|
|
130
|
+
"denied": "تم رفض تسجيل الدخول في Commons.",
|
|
131
|
+
"expired": "انتهت صلاحية طلب تسجيل الدخول. يُرجى المحاولة مرة أخرى.",
|
|
132
|
+
"network": "تعذّر الاتصال بـ Oxy. تحقّق من اتصالك وحاول مرة أخرى.",
|
|
133
|
+
"notConfigured": "هذا التطبيق غير مُعدّ لتسجيل الدخول بعد.",
|
|
134
|
+
"unsupportedFlow": "لا يمكن إكمال تسجيل الدخول هنا.",
|
|
135
|
+
"claimFailed": "تمت الموافقة على تسجيل الدخول، لكن تعذّر إكماله. يُرجى المحاولة مرة أخرى.",
|
|
136
|
+
"generic": "تعذّر تسجيل الدخول. يُرجى المحاولة مرة أخرى."
|
|
137
|
+
},
|
|
138
|
+
"linkOpenFailed": "تعذّر فتح الرابط. يُرجى المحاولة مرة أخرى.",
|
|
139
|
+
"continueWithPasskey": "متابعة",
|
|
140
|
+
"passkeyHint": "استخدم بصمتك أو وجهك أو رمز PIN للجهاز.",
|
|
141
|
+
"otherDeviceCommons": "حسابك على جهاز آخر؟ امسح الرمز باستخدام Commons"
|
|
140
142
|
},
|
|
141
143
|
"common": {
|
|
142
144
|
"actions": {
|
|
@@ -277,6 +279,7 @@
|
|
|
277
279
|
"help": "المساعدة والملاحظات",
|
|
278
280
|
"signOut": "تسجيل الخروج",
|
|
279
281
|
"privacy": "سياسة الخصوصية",
|
|
280
|
-
"terms": "شروط الخدمة"
|
|
282
|
+
"terms": "شروط الخدمة",
|
|
283
|
+
"identity": "هويتك"
|
|
281
284
|
}
|
|
282
285
|
}
|
|
@@ -52,19 +52,9 @@
|
|
|
52
52
|
"signup": {
|
|
53
53
|
"title": "Crea el teu compte",
|
|
54
54
|
"subtitle": "Una identitat per a tot l'ecosistema.",
|
|
55
|
-
"commonsHeadline": "Crea la teva identitat a Commons",
|
|
56
|
-
"commonsExplainer": "Commons és la teva identitat d'Oxy amb autocustòdia completa: una app que funciona a tot arreu.",
|
|
57
55
|
"createInCommons": "Crea la teva identitat a Commons",
|
|
58
|
-
"passkeyAlternative": "No tens Commons? Crea una clau d'accés en aquest dispositiu",
|
|
59
56
|
"createAccount": "Crear compte",
|
|
60
|
-
"creating": "Creant…",
|
|
61
|
-
"usernamePlaceholder": "Tria un nom d'usuari",
|
|
62
|
-
"usernameChecking": "Comprovant…",
|
|
63
|
-
"usernameAvailable": "Disponible",
|
|
64
|
-
"usernameTaken": "Aquest nom d'usuari ja està en ús",
|
|
65
57
|
"backToSignInLink": "Ja tens compte? Inicia sessió",
|
|
66
|
-
"hubExplainer": "Crea la teva Oxy ID en una finestra segura i torna de seguida.",
|
|
67
|
-
"continueInWindow": "Continua en una finestra nova",
|
|
68
58
|
"welcome": {
|
|
69
59
|
"title": "Benvingut a Oxy!",
|
|
70
60
|
"subtitle": "Crea el teu compte en pocs passos",
|
|
@@ -119,7 +109,6 @@
|
|
|
119
109
|
"qrHeadline": "Inicia sessió amb la teva identitat d'Oxy",
|
|
120
110
|
"signInWithOxy": "Inicia sessió amb Oxy",
|
|
121
111
|
"useIdentityOnDevice": "No tens Commons? Fes servir una clau d'accés en aquest dispositiu",
|
|
122
|
-
"passkeySigningIn": "Iniciant sessió…",
|
|
123
112
|
"getCommons": "Descarrega Commons",
|
|
124
113
|
"commonsNotInstalled": "No tens Commons? Descarrega l'app per iniciar sessió amb el teu Oxy ID.",
|
|
125
114
|
"showQrAnyway": "Tinc Commons en un altre dispositiu",
|
|
@@ -136,7 +125,20 @@
|
|
|
136
125
|
"openedInCommons": "Obert a Commons",
|
|
137
126
|
"confirming": "Confirmant la identitat",
|
|
138
127
|
"confirmed": "Identitat confirmada"
|
|
139
|
-
}
|
|
128
|
+
},
|
|
129
|
+
"signInFailures": {
|
|
130
|
+
"denied": "S'ha rebutjat l'inici de sessió a Commons.",
|
|
131
|
+
"expired": "La sol·licitud d'inici de sessió ha caducat. Torna-ho a provar.",
|
|
132
|
+
"network": "No s'ha pogut connectar amb Oxy. Comprova la connexió i torna-ho a provar.",
|
|
133
|
+
"notConfigured": "Aquesta app encara no està preparada per iniciar sessió.",
|
|
134
|
+
"unsupportedFlow": "Aquest inici de sessió no es pot completar aquí.",
|
|
135
|
+
"claimFailed": "S'ha aprovat l'inici de sessió, però no l'hem pogut completar. Torna-ho a provar.",
|
|
136
|
+
"generic": "No s'ha pogut iniciar la sessió. Torna-ho a provar."
|
|
137
|
+
},
|
|
138
|
+
"linkOpenFailed": "No s'ha pogut obrir l'enllaç. Torna-ho a provar.",
|
|
139
|
+
"continueWithPasskey": "Continua",
|
|
140
|
+
"passkeyHint": "Fes servir l'empremta, la cara o el PIN del dispositiu.",
|
|
141
|
+
"otherDeviceCommons": "El teu compte és en un altre dispositiu? Escaneja amb Commons"
|
|
140
142
|
},
|
|
141
143
|
"common": {
|
|
142
144
|
"actions": {
|
|
@@ -277,6 +279,7 @@
|
|
|
277
279
|
"help": "Ajuda i comentaris",
|
|
278
280
|
"signOut": "Tancar sessió",
|
|
279
281
|
"privacy": "Política de privacitat",
|
|
280
|
-
"terms": "Condicions del servei"
|
|
282
|
+
"terms": "Condicions del servei",
|
|
283
|
+
"identity": "La teva identitat"
|
|
281
284
|
}
|
|
282
285
|
}
|