@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,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity move — the crypto of taking a web identity into Commons.
|
|
3
|
+
*
|
|
4
|
+
* Both carriers run this: `id.oxy.so` (the initiator, holding the identity) and
|
|
5
|
+
* Commons (the responder, receiving it). The relay in between sees two ephemeral
|
|
6
|
+
* public keys and ciphertext; the 6-digit SAS both screens show is what makes a
|
|
7
|
+
* relay that swapped a key visible to the person.
|
|
8
|
+
*
|
|
9
|
+
* PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
|
|
10
|
+
*/
|
|
11
|
+
import './polyfill.js';
|
|
12
|
+
import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
|
|
13
|
+
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
14
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
15
|
+
import { signMessage, verifySignature } from '@oxy.so/protocol';
|
|
16
|
+
import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
|
|
17
|
+
import { IDENTITY_MOVE_QR_PREFIX } from '@oxy.so/contracts';
|
|
18
|
+
import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead.js';
|
|
19
|
+
import { deriveSharedSecret } from './ecdh.js';
|
|
20
|
+
import { hkdfSha256 } from './kdf.js';
|
|
21
|
+
import { deriveIdentityFromMnemonic, deriveTransferSas, wipeBytes } from './webIdentityCarrier.js';
|
|
22
|
+
const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
|
|
23
|
+
/** Actions the identity key signs during a move. */
|
|
24
|
+
export const IDENTITY_MOVE_ACTIONS = {
|
|
25
|
+
seal: 'identity_move_seal',
|
|
26
|
+
received: 'identity_move_received',
|
|
27
|
+
};
|
|
28
|
+
/** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
|
|
29
|
+
export function generateMoveEphemeralKeyPair() {
|
|
30
|
+
return generateSecp256k1KeyPair();
|
|
31
|
+
}
|
|
32
|
+
/** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
|
|
33
|
+
export function deriveMoveKey(ownEphemeralPrivateKey, otherEphemeralPublicKey, moveId) {
|
|
34
|
+
const shared = deriveSharedSecret(ownEphemeralPrivateKey, otherEphemeralPublicKey);
|
|
35
|
+
try {
|
|
36
|
+
return hkdfSha256(shared, utf8ToBytes(moveId.toLowerCase()), MOVE_KDF_INFO, AEAD_KEY_LENGTH);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
wipeBytes(shared);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The 6-digit code both screens show, bound to the move and both ephemeral keys in their roles. */
|
|
43
|
+
export function deriveMoveSas(moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey) {
|
|
44
|
+
return deriveTransferSas({ pairingId: moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey });
|
|
45
|
+
}
|
|
46
|
+
function moveAad(moveId, publicKey) {
|
|
47
|
+
return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
|
|
48
|
+
}
|
|
49
|
+
/** Initiator: seal the identity's entropy for the responder. */
|
|
50
|
+
export function sealIdentityForMove(identity, moveKey, moveId) {
|
|
51
|
+
const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
|
|
52
|
+
try {
|
|
53
|
+
const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
|
|
54
|
+
return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(ciphertext) };
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
wipeBytes(entropy);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Responder: open the sealed identity. The derived public key must be the one
|
|
62
|
+
* the move declared — anything else is refused, never imported.
|
|
63
|
+
*/
|
|
64
|
+
export function openMovedIdentity(sealed, moveKey, moveId, expectedPublicKey) {
|
|
65
|
+
let entropy;
|
|
66
|
+
try {
|
|
67
|
+
entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error('The identity could not be opened. The codes may not have matched — start again.');
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
|
|
74
|
+
if (identity.publicKey !== expectedPublicKey.toLowerCase()) {
|
|
75
|
+
throw new Error('The received identity is not the one being moved');
|
|
76
|
+
}
|
|
77
|
+
return identity;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
wipeBytes(entropy);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
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
|
+
/** The QR payload for a move. */
|
|
100
|
+
export function buildMoveQrPayload(moveId) {
|
|
101
|
+
return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
|
|
102
|
+
}
|
|
103
|
+
/** The move id in a scanned payload, or `null` when it is not a move QR. */
|
|
104
|
+
export function parseMoveQrPayload(raw) {
|
|
105
|
+
const value = raw.trim();
|
|
106
|
+
if (!value.toLowerCase().startsWith(IDENTITY_MOVE_QR_PREFIX))
|
|
107
|
+
return null;
|
|
108
|
+
const id = value.slice(IDENTITY_MOVE_QR_PREFIX.length).toLowerCase();
|
|
109
|
+
return /^[0-9a-f]{32}$/.test(id) ? id : null;
|
|
110
|
+
}
|
|
@@ -746,6 +746,42 @@ export class KeyManager {
|
|
|
746
746
|
return null;
|
|
747
747
|
}
|
|
748
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* Which identity key this device is actually acting as, per slot.
|
|
751
|
+
*
|
|
752
|
+
* There are two slots and they can disagree. The shared slot is what every
|
|
753
|
+
* cross-app reader takes first — `deriveScopedSeed` (so: the FairCoin wallet
|
|
754
|
+
* Peable derives) and `getSharedPrivateKey` — while the primary slot is what
|
|
755
|
+
* signing and the server-facing identity use. A disagreement is invisible to
|
|
756
|
+
* both sides on its own, and it is money: a wallet derived from a stale
|
|
757
|
+
* shared key watches addresses that the key published in the DID will never
|
|
758
|
+
* produce, so payments land where the recipient cannot see them.
|
|
759
|
+
*
|
|
760
|
+
* `activePublicKey` is the one money derives from — the same `shared ?? primary`
|
|
761
|
+
* order `deriveScopedSeed` uses, so a caller can compare it against the key
|
|
762
|
+
* published for the account and refuse to receive rather than watch the wrong
|
|
763
|
+
* addresses.
|
|
764
|
+
*
|
|
765
|
+
* Public keys are canonicalized, so two encodings of the same key compare
|
|
766
|
+
* equal. On web both slots are null and `inSync` is true: nothing is stored,
|
|
767
|
+
* so nothing can disagree.
|
|
768
|
+
*/
|
|
769
|
+
static async getIdentityKeyState() {
|
|
770
|
+
const [primaryRaw, sharedRaw] = await Promise.all([
|
|
771
|
+
KeyManager.getPublicKey(),
|
|
772
|
+
KeyManager.getSharedPublicKey(),
|
|
773
|
+
]);
|
|
774
|
+
const primaryPublicKey = primaryRaw ? KeyManager.canonicalPublicKey(primaryRaw) : null;
|
|
775
|
+
const sharedPublicKey = sharedRaw ? KeyManager.canonicalPublicKey(sharedRaw) : null;
|
|
776
|
+
return {
|
|
777
|
+
primaryPublicKey,
|
|
778
|
+
sharedPublicKey,
|
|
779
|
+
activePublicKey: sharedPublicKey ?? primaryPublicKey,
|
|
780
|
+
// Only a real disagreement is out of sync. A device with just one slot
|
|
781
|
+
// populated has nothing to contradict.
|
|
782
|
+
inSync: primaryPublicKey === null || sharedPublicKey === null || primaryPublicKey === sharedPublicKey,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
749
785
|
/**
|
|
750
786
|
* Check if a shared identity exists (accessible across all Oxy apps)
|
|
751
787
|
*
|
|
@@ -939,49 +975,52 @@ export class KeyManager {
|
|
|
939
975
|
}
|
|
940
976
|
}
|
|
941
977
|
/**
|
|
942
|
-
*
|
|
978
|
+
* Make the shared slot hold THIS device's identity key.
|
|
943
979
|
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
980
|
+
* The shared slot is the one every Oxy app on the device reads, so it has to
|
|
981
|
+
* agree with the primary slot. This creates it when it is empty (the original
|
|
982
|
+
* migration, for apps that predate the shared identity) and repairs it when it
|
|
983
|
+
* holds a different key.
|
|
947
984
|
*
|
|
948
|
-
* @returns True
|
|
985
|
+
* @returns True when the shared slot ends up holding the device's key, false
|
|
986
|
+
* when there is no identity to share.
|
|
949
987
|
*/
|
|
950
|
-
static async
|
|
988
|
+
static async syncSharedIdentity() {
|
|
951
989
|
if (isWebPlatform()) {
|
|
952
990
|
return false;
|
|
953
991
|
}
|
|
954
992
|
try {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
if (hasShared) {
|
|
958
|
-
if (isDev()) {
|
|
959
|
-
logger.debug('Shared identity already exists, skipping migration', { component: 'KeyManager' });
|
|
960
|
-
}
|
|
993
|
+
const state = await KeyManager.getIdentityKeyState();
|
|
994
|
+
if (state.sharedPublicKey !== null && (state.inSync || state.primaryPublicKey === null)) {
|
|
961
995
|
return true;
|
|
962
996
|
}
|
|
963
|
-
// Get local identity
|
|
964
997
|
const privateKey = await KeyManager.getPrivateKey();
|
|
965
998
|
if (!privateKey) {
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
return false;
|
|
999
|
+
// Nothing of this device's own to share. A shared slot that is already
|
|
1000
|
+
// populated stays as it is: it is the only identity here.
|
|
1001
|
+
return state.sharedPublicKey !== null;
|
|
970
1002
|
}
|
|
971
|
-
// Import to shared storage
|
|
972
1003
|
await KeyManager.importSharedIdentity(privateKey);
|
|
973
|
-
|
|
974
|
-
|
|
1004
|
+
// A slot that held a DIFFERENT key is the dangerous case, so it is said
|
|
1005
|
+
// out loud rather than debug-logged: until this write, cross-app readers
|
|
1006
|
+
// (`deriveScopedSeed`, so Peable's wallet) were deriving from one key
|
|
1007
|
+
// while signing and the server used another.
|
|
1008
|
+
if (state.sharedPublicKey !== null) {
|
|
1009
|
+
logger.warn('Shared identity held a different key than this device: repaired it from the primary slot.', { component: 'KeyManager', method: 'syncSharedIdentity' });
|
|
975
1010
|
}
|
|
976
1011
|
return true;
|
|
977
1012
|
}
|
|
978
1013
|
catch (error) {
|
|
979
1014
|
if (isDev()) {
|
|
980
|
-
logger.error('Failed to
|
|
1015
|
+
logger.error('Failed to sync the shared identity', error, { component: 'KeyManager' });
|
|
981
1016
|
}
|
|
982
1017
|
return false;
|
|
983
1018
|
}
|
|
984
1019
|
}
|
|
1020
|
+
/** @deprecated Renamed to {@link KeyManager.syncSharedIdentity}. */
|
|
1021
|
+
static async migrateToSharedIdentity() {
|
|
1022
|
+
return KeyManager.syncSharedIdentity();
|
|
1023
|
+
}
|
|
985
1024
|
// ==================== END SHARED IDENTITY METHODS ====================
|
|
986
1025
|
/**
|
|
987
1026
|
* Atomically persist a key pair to secure storage with verification + backup.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web identity carrier — the crypto of "one identity, two carriers".
|
|
3
|
+
*
|
|
4
|
+
* An Oxy identity is a BIP-39 mnemonic; its seed's first 32 bytes are the
|
|
5
|
+
* secp256k1 key (`recoveryPhrase.ts`). Commons keeps that key in the device
|
|
6
|
+
* keychain. This module lets a browser carry the SAME identity without Oxy ever
|
|
7
|
+
* being able to use it:
|
|
8
|
+
*
|
|
9
|
+
* entropy ──AEAD(DEK)──▶ sealedEntropy
|
|
10
|
+
* DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
|
|
11
|
+
*
|
|
12
|
+
* The PRF output is produced inside the user's authenticator, behind user
|
|
13
|
+
* verification, and never leaves the page that asked for it. The envelope is
|
|
14
|
+
* therefore safe to store anywhere — the identity origin's IndexedDB and the
|
|
15
|
+
* server copy alike — and opens only with a registered passkey or the phrase.
|
|
16
|
+
*
|
|
17
|
+
* PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
|
|
18
|
+
* CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
|
|
19
|
+
* PRF output in; everything here is deterministic given its inputs, so it is
|
|
20
|
+
* identical on web, Node and React Native, and fully unit-testable.
|
|
21
|
+
*
|
|
22
|
+
* Contract: `WebIdentityEnvelope` in `@oxy.so/contracts`.
|
|
23
|
+
*/
|
|
24
|
+
import './polyfill.js';
|
|
25
|
+
import { entropyToMnemonic, generateMnemonic, mnemonicToEntropy, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
|
26
|
+
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
27
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
28
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
29
|
+
import { signMessage } from '@oxy.so/protocol';
|
|
30
|
+
import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
|
|
31
|
+
import { WEB_IDENTITY_ENVELOPE_VERSION, } from '@oxy.so/contracts';
|
|
32
|
+
import { AEAD_KEY_LENGTH, decryptAead, encryptAead } from './aead.js';
|
|
33
|
+
import { hkdfSha256 } from './kdf.js';
|
|
34
|
+
/**
|
|
35
|
+
* The PRF input every identity-carrier ceremony evaluates (`prf.eval.first`).
|
|
36
|
+
*
|
|
37
|
+
* Fixed rather than per-wrap: discoverable sign-in has no credential list to key
|
|
38
|
+
* `evalByCredential` by, and each passkey's output is already distinct. A future
|
|
39
|
+
* scheme change bumps the label — and with it every KEK — instead of mutating it.
|
|
40
|
+
*/
|
|
41
|
+
export const WEB_IDENTITY_PRF_INPUT = sha256(utf8ToBytes('oxy-web-identity-carrier/prf/v1'));
|
|
42
|
+
/** HKDF salt of the key-encryption-key schedule. Versioned with the envelope. */
|
|
43
|
+
const KEK_SALT = utf8ToBytes('oxy-web-identity-carrier/kek/v1');
|
|
44
|
+
/** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). */
|
|
45
|
+
const PRF_OUTPUT_LENGTH = 32;
|
|
46
|
+
export class WebIdentityUnlockError extends Error {
|
|
47
|
+
constructor(failure, message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.failure = failure;
|
|
50
|
+
this.name = 'WebIdentityUnlockError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function aadFor(parts) {
|
|
54
|
+
return utf8ToBytes(JSON.stringify(parts));
|
|
55
|
+
}
|
|
56
|
+
function entropyAad(publicKey) {
|
|
57
|
+
return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'entropy', publicKey });
|
|
58
|
+
}
|
|
59
|
+
function wrapAad(publicKey, credentialId) {
|
|
60
|
+
return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId });
|
|
61
|
+
}
|
|
62
|
+
/** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
|
|
63
|
+
export function wipeBytes(bytes) {
|
|
64
|
+
bytes.fill(0);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Derive the identity from a mnemonic exactly as Commons does
|
|
68
|
+
* (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
|
|
69
|
+
*/
|
|
70
|
+
export function deriveIdentityFromMnemonic(mnemonic) {
|
|
71
|
+
const normalized = mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
|
|
72
|
+
if (!validateMnemonic(normalized, wordlist)) {
|
|
73
|
+
throw new Error('Invalid recovery phrase');
|
|
74
|
+
}
|
|
75
|
+
const seed = mnemonicToSeedSync(normalized);
|
|
76
|
+
const privateKeyBytes = seed.slice(0, 32);
|
|
77
|
+
wipeBytes(seed);
|
|
78
|
+
const privateKey = bytesToHex(privateKeyBytes);
|
|
79
|
+
wipeBytes(privateKeyBytes);
|
|
80
|
+
return { mnemonic: normalized, privateKey, publicKey: deriveSecp256k1PublicKey(privateKey) };
|
|
81
|
+
}
|
|
82
|
+
/** A brand-new 12-word identity. Nothing is persisted. */
|
|
83
|
+
export function generateWebIdentity() {
|
|
84
|
+
return deriveIdentityFromMnemonic(generateMnemonic(wordlist, 128));
|
|
85
|
+
}
|
|
86
|
+
/** A fresh random data key for a new envelope. */
|
|
87
|
+
export function generateDataKey() {
|
|
88
|
+
const key = new Uint8Array(AEAD_KEY_LENGTH);
|
|
89
|
+
globalThis.crypto.getRandomValues(key);
|
|
90
|
+
return key;
|
|
91
|
+
}
|
|
92
|
+
/** The key-encryption key a passkey's PRF output yields for its own wrap. */
|
|
93
|
+
export function deriveKeyEncryptionKey(prfOutput, credentialId) {
|
|
94
|
+
if (prfOutput.length !== PRF_OUTPUT_LENGTH) {
|
|
95
|
+
throw new Error(`PRF output must be ${PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
|
|
96
|
+
}
|
|
97
|
+
return hkdfSha256(prfOutput, KEK_SALT, utf8ToBytes(credentialId), AEAD_KEY_LENGTH);
|
|
98
|
+
}
|
|
99
|
+
/** Wrap `dataKey` for one passkey. */
|
|
100
|
+
export function wrapDataKey(dataKey, prfOutput, credentialId, publicKey, now = new Date()) {
|
|
101
|
+
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
102
|
+
try {
|
|
103
|
+
const { nonce, ciphertext } = encryptAead(kek, dataKey, wrapAad(publicKey, credentialId));
|
|
104
|
+
return {
|
|
105
|
+
credentialId,
|
|
106
|
+
nonce: bytesToHex(nonce),
|
|
107
|
+
wrappedKey: bytesToHex(ciphertext),
|
|
108
|
+
createdAt: now.toISOString(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
wipeBytes(kek);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Seal an identity into a new envelope that opens with the given passkey.
|
|
117
|
+
*
|
|
118
|
+
* Returns the data key too, so the caller can add further wraps in the same
|
|
119
|
+
* session; wipe it when done.
|
|
120
|
+
*/
|
|
121
|
+
export function sealWebIdentity(identity, firstWrap, now = new Date()) {
|
|
122
|
+
const derived = deriveIdentityFromMnemonic(identity.mnemonic);
|
|
123
|
+
if (derived.publicKey !== identity.publicKey.toLowerCase()) {
|
|
124
|
+
throw new Error('The recovery phrase does not belong to this identity');
|
|
125
|
+
}
|
|
126
|
+
const dataKey = generateDataKey();
|
|
127
|
+
const entropy = mnemonicToEntropy(derived.mnemonic, wordlist);
|
|
128
|
+
try {
|
|
129
|
+
const { nonce, ciphertext } = encryptAead(dataKey, entropy, entropyAad(derived.publicKey));
|
|
130
|
+
const envelope = {
|
|
131
|
+
version: WEB_IDENTITY_ENVELOPE_VERSION,
|
|
132
|
+
algorithm: 'xchacha20poly1305',
|
|
133
|
+
publicKey: derived.publicKey,
|
|
134
|
+
entropyNonce: bytesToHex(nonce),
|
|
135
|
+
sealedEntropy: bytesToHex(ciphertext),
|
|
136
|
+
wraps: [wrapDataKey(dataKey, firstWrap.prfOutput, firstWrap.credentialId, derived.publicKey, now)],
|
|
137
|
+
};
|
|
138
|
+
return { envelope, dataKey };
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
wipeBytes(entropy);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Recover the data key with one passkey's PRF output. */
|
|
145
|
+
export function unwrapDataKey(envelope, prfOutput, credentialId) {
|
|
146
|
+
const wrap = envelope.wraps.find((entry) => entry.credentialId === credentialId);
|
|
147
|
+
if (!wrap) {
|
|
148
|
+
throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
|
|
149
|
+
}
|
|
150
|
+
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
151
|
+
try {
|
|
152
|
+
return decryptAead(kek, hexToBytes(wrap.nonce), hexToBytes(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
wipeBytes(kek);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Open an envelope with its data key.
|
|
163
|
+
*
|
|
164
|
+
* The derived public key is checked against the envelope's own: a sealed entropy
|
|
165
|
+
* that decrypts into a different identity is treated as corruption, never used.
|
|
166
|
+
*/
|
|
167
|
+
export function openWebIdentity(envelope, dataKey) {
|
|
168
|
+
let entropy;
|
|
169
|
+
try {
|
|
170
|
+
entropy = decryptAead(dataKey, hexToBytes(envelope.entropyNonce), hexToBytes(envelope.sealedEntropy), entropyAad(envelope.publicKey));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
|
|
177
|
+
if (identity.publicKey !== envelope.publicKey.toLowerCase()) {
|
|
178
|
+
throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
|
|
179
|
+
}
|
|
180
|
+
return identity;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
wipeBytes(entropy);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Unwrap and open in one step. */
|
|
187
|
+
export function unlockWebIdentity(envelope, prfOutput, credentialId) {
|
|
188
|
+
const dataKey = unwrapDataKey(envelope, prfOutput, credentialId);
|
|
189
|
+
try {
|
|
190
|
+
return openWebIdentity(envelope, dataKey);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
wipeBytes(dataKey);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
|
|
197
|
+
export function addWrap(envelope, dataKey, prfOutput, credentialId, now = new Date()) {
|
|
198
|
+
// 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);
|
|
201
|
+
return {
|
|
202
|
+
...envelope,
|
|
203
|
+
wraps: [...others, wrapDataKey(dataKey, prfOutput, credentialId, envelope.publicKey, now)],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
|
|
207
|
+
export function removeWrap(envelope, credentialId) {
|
|
208
|
+
const remaining = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
|
|
209
|
+
if (remaining.length === 0) {
|
|
210
|
+
throw new Error('An identity envelope must keep at least one passkey');
|
|
211
|
+
}
|
|
212
|
+
return { ...envelope, wraps: remaining };
|
|
213
|
+
}
|
|
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -52,19 +52,9 @@
|
|
|
52
52
|
"signup": {
|
|
53
53
|
"title": "Erstellen Sie Ihr Konto",
|
|
54
54
|
"subtitle": "Eine Identität für das gesamte Ökosystem.",
|
|
55
|
-
"commonsHeadline": "Erstellen Sie Ihre Identität in Commons",
|
|
56
|
-
"commonsExplainer": "Commons ist Ihre vollständig selbstverwaltete Oxy-Identität – eine App, überall einsetzbar.",
|
|
57
55
|
"createInCommons": "Erstellen Sie Ihre Identität in Commons",
|
|
58
|
-
"passkeyAlternative": "Kein Commons? Erstellen Sie stattdessen einen Passkey auf diesem Gerät",
|
|
59
56
|
"createAccount": "Konto erstellen",
|
|
60
|
-
"creating": "Wird erstellt…",
|
|
61
|
-
"usernamePlaceholder": "Benutzernamen wählen",
|
|
62
|
-
"usernameChecking": "Wird geprüft…",
|
|
63
|
-
"usernameAvailable": "Verfügbar",
|
|
64
|
-
"usernameTaken": "Dieser Benutzername ist bereits vergeben",
|
|
65
57
|
"backToSignInLink": "Sie haben bereits ein Konto? Anmelden",
|
|
66
|
-
"hubExplainer": "Erstellen Sie Ihre Oxy-ID in einem sicheren Fenster und kehren Sie dann direkt zurück.",
|
|
67
|
-
"continueInWindow": "In einem neuen Fenster fortfahren",
|
|
68
58
|
"welcome": {
|
|
69
59
|
"title": "Willkommen bei Oxy!",
|
|
70
60
|
"subtitle": "Erstellen Sie Ihr Konto in nur wenigen Schritten",
|
|
@@ -119,7 +109,6 @@
|
|
|
119
109
|
"qrHeadline": "Melden Sie sich mit Ihrer Oxy-Identität an",
|
|
120
110
|
"signInWithOxy": "Mit Oxy anmelden",
|
|
121
111
|
"useIdentityOnDevice": "Kein Commons? Verwenden Sie stattdessen einen Passkey auf diesem Gerät",
|
|
122
|
-
"passkeySigningIn": "Anmeldung läuft…",
|
|
123
112
|
"getCommons": "Commons herunterladen",
|
|
124
113
|
"commonsNotInstalled": "Kein Commons? Holen Sie sich die App, um sich mit Ihrer Oxy-ID anzumelden.",
|
|
125
114
|
"showQrAnyway": "Ich habe Commons auf einem anderen Gerät",
|
|
@@ -136,7 +125,20 @@
|
|
|
136
125
|
"openedInCommons": "In Commons geöffnet",
|
|
137
126
|
"confirming": "Identität wird bestätigt",
|
|
138
127
|
"confirmed": "Identität bestätigt"
|
|
139
|
-
}
|
|
128
|
+
},
|
|
129
|
+
"signInFailures": {
|
|
130
|
+
"denied": "Die Anmeldung wurde in Commons abgelehnt.",
|
|
131
|
+
"expired": "Die Anmeldeanfrage ist abgelaufen. Bitte versuchen Sie es erneut.",
|
|
132
|
+
"network": "Oxy ist nicht erreichbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
|
133
|
+
"notConfigured": "Diese App ist noch nicht für die Anmeldung eingerichtet.",
|
|
134
|
+
"unsupportedFlow": "Diese Anmeldung kann hier nicht abgeschlossen werden.",
|
|
135
|
+
"claimFailed": "Die Anmeldung wurde genehmigt, konnte aber nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
|
136
|
+
"generic": "Die Anmeldung ist fehlgeschlagen. Bitte versuchen Sie es erneut."
|
|
137
|
+
},
|
|
138
|
+
"linkOpenFailed": "Der Link konnte nicht geöffnet werden. Bitte versuchen Sie es erneut.",
|
|
139
|
+
"continueWithPasskey": "Weiter",
|
|
140
|
+
"passkeyHint": "Nutzen Sie Fingerabdruck, Gesicht oder die Geräte-PIN.",
|
|
141
|
+
"otherDeviceCommons": "Konto auf einem anderen Gerät? Mit Commons scannen"
|
|
140
142
|
},
|
|
141
143
|
"common": {
|
|
142
144
|
"actions": {
|
|
@@ -277,6 +279,7 @@
|
|
|
277
279
|
"help": "Hilfe & Feedback",
|
|
278
280
|
"signOut": "Abmelden",
|
|
279
281
|
"privacy": "Datenschutzerklärung",
|
|
280
|
-
"terms": "Nutzungsbedingungen"
|
|
282
|
+
"terms": "Nutzungsbedingungen",
|
|
283
|
+
"identity": "Ihre Identität"
|
|
281
284
|
}
|
|
282
285
|
}
|