@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
package/src/crypto/keyManager.ts
CHANGED
|
@@ -991,6 +991,49 @@ export class KeyManager {
|
|
|
991
991
|
}
|
|
992
992
|
}
|
|
993
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Which identity key this device is actually acting as, per slot.
|
|
996
|
+
*
|
|
997
|
+
* There are two slots and they can disagree. The shared slot is what every
|
|
998
|
+
* cross-app reader takes first — `deriveScopedSeed` (so: the FairCoin wallet
|
|
999
|
+
* Peable derives) and `getSharedPrivateKey` — while the primary slot is what
|
|
1000
|
+
* signing and the server-facing identity use. A disagreement is invisible to
|
|
1001
|
+
* both sides on its own, and it is money: a wallet derived from a stale
|
|
1002
|
+
* shared key watches addresses that the key published in the DID will never
|
|
1003
|
+
* produce, so payments land where the recipient cannot see them.
|
|
1004
|
+
*
|
|
1005
|
+
* `activePublicKey` is the one money derives from — the same `shared ?? primary`
|
|
1006
|
+
* order `deriveScopedSeed` uses, so a caller can compare it against the key
|
|
1007
|
+
* published for the account and refuse to receive rather than watch the wrong
|
|
1008
|
+
* addresses.
|
|
1009
|
+
*
|
|
1010
|
+
* Public keys are canonicalized, so two encodings of the same key compare
|
|
1011
|
+
* equal. On web both slots are null and `inSync` is true: nothing is stored,
|
|
1012
|
+
* so nothing can disagree.
|
|
1013
|
+
*/
|
|
1014
|
+
static async getIdentityKeyState(): Promise<{
|
|
1015
|
+
primaryPublicKey: string | null;
|
|
1016
|
+
sharedPublicKey: string | null;
|
|
1017
|
+
activePublicKey: string | null;
|
|
1018
|
+
inSync: boolean;
|
|
1019
|
+
}> {
|
|
1020
|
+
const [primaryRaw, sharedRaw] = await Promise.all([
|
|
1021
|
+
KeyManager.getPublicKey(),
|
|
1022
|
+
KeyManager.getSharedPublicKey(),
|
|
1023
|
+
]);
|
|
1024
|
+
const primaryPublicKey = primaryRaw ? KeyManager.canonicalPublicKey(primaryRaw) : null;
|
|
1025
|
+
const sharedPublicKey = sharedRaw ? KeyManager.canonicalPublicKey(sharedRaw) : null;
|
|
1026
|
+
return {
|
|
1027
|
+
primaryPublicKey,
|
|
1028
|
+
sharedPublicKey,
|
|
1029
|
+
activePublicKey: sharedPublicKey ?? primaryPublicKey,
|
|
1030
|
+
// Only a real disagreement is out of sync. A device with just one slot
|
|
1031
|
+
// populated has nothing to contradict.
|
|
1032
|
+
inSync:
|
|
1033
|
+
primaryPublicKey === null || sharedPublicKey === null || primaryPublicKey === sharedPublicKey,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
994
1037
|
/**
|
|
995
1038
|
* Check if a shared identity exists (accessible across all Oxy apps)
|
|
996
1039
|
*
|
|
@@ -1201,54 +1244,61 @@ export class KeyManager {
|
|
|
1201
1244
|
}
|
|
1202
1245
|
|
|
1203
1246
|
/**
|
|
1204
|
-
*
|
|
1247
|
+
* Make the shared slot hold THIS device's identity key.
|
|
1205
1248
|
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1249
|
+
* The shared slot is the one every Oxy app on the device reads, so it has to
|
|
1250
|
+
* agree with the primary slot. This creates it when it is empty (the original
|
|
1251
|
+
* migration, for apps that predate the shared identity) and repairs it when it
|
|
1252
|
+
* holds a different key.
|
|
1209
1253
|
*
|
|
1210
|
-
* @returns True
|
|
1254
|
+
* @returns True when the shared slot ends up holding the device's key, false
|
|
1255
|
+
* when there is no identity to share.
|
|
1211
1256
|
*/
|
|
1212
|
-
static async
|
|
1257
|
+
static async syncSharedIdentity(): Promise<boolean> {
|
|
1213
1258
|
if (isWebPlatform()) {
|
|
1214
1259
|
return false;
|
|
1215
1260
|
}
|
|
1216
1261
|
|
|
1217
1262
|
try {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
if (hasShared) {
|
|
1221
|
-
if (isDev()) {
|
|
1222
|
-
logger.debug('Shared identity already exists, skipping migration', { component: 'KeyManager' });
|
|
1223
|
-
}
|
|
1263
|
+
const state = await KeyManager.getIdentityKeyState();
|
|
1264
|
+
if (state.sharedPublicKey !== null && (state.inSync || state.primaryPublicKey === null)) {
|
|
1224
1265
|
return true;
|
|
1225
1266
|
}
|
|
1226
1267
|
|
|
1227
|
-
// Get local identity
|
|
1228
1268
|
const privateKey = await KeyManager.getPrivateKey();
|
|
1229
1269
|
if (!privateKey) {
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
return false;
|
|
1270
|
+
// Nothing of this device's own to share. A shared slot that is already
|
|
1271
|
+
// populated stays as it is: it is the only identity here.
|
|
1272
|
+
return state.sharedPublicKey !== null;
|
|
1234
1273
|
}
|
|
1235
1274
|
|
|
1236
|
-
// Import to shared storage
|
|
1237
1275
|
await KeyManager.importSharedIdentity(privateKey);
|
|
1238
1276
|
|
|
1239
|
-
|
|
1240
|
-
|
|
1277
|
+
// A slot that held a DIFFERENT key is the dangerous case, so it is said
|
|
1278
|
+
// out loud rather than debug-logged: until this write, cross-app readers
|
|
1279
|
+
// (`deriveScopedSeed`, so Peable's wallet) were deriving from one key
|
|
1280
|
+
// while signing and the server used another.
|
|
1281
|
+
if (state.sharedPublicKey !== null) {
|
|
1282
|
+
logger.warn(
|
|
1283
|
+
'Shared identity held a different key than this device: repaired it from the primary slot.',
|
|
1284
|
+
{ component: 'KeyManager', method: 'syncSharedIdentity' },
|
|
1285
|
+
);
|
|
1241
1286
|
}
|
|
1242
1287
|
|
|
1243
1288
|
return true;
|
|
1244
1289
|
} catch (error) {
|
|
1245
1290
|
if (isDev()) {
|
|
1246
|
-
logger.error('Failed to
|
|
1291
|
+
logger.error('Failed to sync the shared identity', error, { component: 'KeyManager' });
|
|
1247
1292
|
}
|
|
1248
1293
|
return false;
|
|
1249
1294
|
}
|
|
1250
1295
|
}
|
|
1251
1296
|
|
|
1297
|
+
/** @deprecated Renamed to {@link KeyManager.syncSharedIdentity}. */
|
|
1298
|
+
static async migrateToSharedIdentity(): Promise<boolean> {
|
|
1299
|
+
return KeyManager.syncSharedIdentity();
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1252
1302
|
// ==================== END SHARED IDENTITY METHODS ====================
|
|
1253
1303
|
|
|
1254
1304
|
/**
|
|
@@ -0,0 +1,326 @@
|
|
|
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
|
+
|
|
25
|
+
import './polyfill';
|
|
26
|
+
import { entropyToMnemonic, generateMnemonic, mnemonicToEntropy, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
|
27
|
+
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
28
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
29
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
30
|
+
import { signMessage } from '@oxy.so/protocol';
|
|
31
|
+
import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
|
|
32
|
+
import {
|
|
33
|
+
WEB_IDENTITY_ENVELOPE_VERSION,
|
|
34
|
+
type WebIdentityEnvelope,
|
|
35
|
+
type WebIdentityWrap,
|
|
36
|
+
} from '@oxy.so/contracts';
|
|
37
|
+
import { AEAD_KEY_LENGTH, decryptAead, encryptAead } from './aead';
|
|
38
|
+
import { hkdfSha256 } from './kdf';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The PRF input every identity-carrier ceremony evaluates (`prf.eval.first`).
|
|
42
|
+
*
|
|
43
|
+
* Fixed rather than per-wrap: discoverable sign-in has no credential list to key
|
|
44
|
+
* `evalByCredential` by, and each passkey's output is already distinct. A future
|
|
45
|
+
* scheme change bumps the label — and with it every KEK — instead of mutating it.
|
|
46
|
+
*/
|
|
47
|
+
export const WEB_IDENTITY_PRF_INPUT: Uint8Array = sha256(utf8ToBytes('oxy-web-identity-carrier/prf/v1'));
|
|
48
|
+
|
|
49
|
+
/** HKDF salt of the key-encryption-key schedule. Versioned with the envelope. */
|
|
50
|
+
const KEK_SALT = utf8ToBytes('oxy-web-identity-carrier/kek/v1');
|
|
51
|
+
|
|
52
|
+
/** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). */
|
|
53
|
+
const PRF_OUTPUT_LENGTH = 32;
|
|
54
|
+
|
|
55
|
+
/** Why an envelope did not open. Every value is a normal outcome a UI can explain. */
|
|
56
|
+
export type WebIdentityUnlockFailure =
|
|
57
|
+
/** The passkey used is not one of the envelope's wraps. */
|
|
58
|
+
| 'unknown-credential'
|
|
59
|
+
/**
|
|
60
|
+
* The passkey IS listed but its PRF output does not open its wrap — a synced
|
|
61
|
+
* copy returning a different PRF value, or a provider that silently changed it.
|
|
62
|
+
* Recoverable with another passkey or the phrase.
|
|
63
|
+
*/
|
|
64
|
+
| 'prf-mismatch'
|
|
65
|
+
/** The envelope itself is malformed or tampered with. */
|
|
66
|
+
| 'corrupt';
|
|
67
|
+
|
|
68
|
+
export class WebIdentityUnlockError extends Error {
|
|
69
|
+
constructor(readonly failure: WebIdentityUnlockFailure, message: string) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = 'WebIdentityUnlockError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The identity in usable form. Hold it for one operation, then {@link wipeBytes}. */
|
|
76
|
+
export interface OpenedWebIdentity {
|
|
77
|
+
mnemonic: string;
|
|
78
|
+
/** 32-byte secp256k1 private key, lowercase hex. */
|
|
79
|
+
privateKey: string;
|
|
80
|
+
/** Uncompressed SEC1 public key, lowercase hex (Oxy's canonical form). */
|
|
81
|
+
publicKey: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function aadFor(parts: Record<string, string | number>): Uint8Array {
|
|
85
|
+
return utf8ToBytes(JSON.stringify(parts));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function entropyAad(publicKey: string): Uint8Array {
|
|
89
|
+
return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'entropy', publicKey });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function wrapAad(publicKey: string, credentialId: string): Uint8Array {
|
|
93
|
+
return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
|
|
97
|
+
export function wipeBytes(bytes: Uint8Array): void {
|
|
98
|
+
bytes.fill(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Derive the identity from a mnemonic exactly as Commons does
|
|
103
|
+
* (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
|
|
104
|
+
*/
|
|
105
|
+
export function deriveIdentityFromMnemonic(mnemonic: string): OpenedWebIdentity {
|
|
106
|
+
const normalized = mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
|
|
107
|
+
if (!validateMnemonic(normalized, wordlist)) {
|
|
108
|
+
throw new Error('Invalid recovery phrase');
|
|
109
|
+
}
|
|
110
|
+
const seed = mnemonicToSeedSync(normalized);
|
|
111
|
+
const privateKeyBytes = seed.slice(0, 32);
|
|
112
|
+
wipeBytes(seed);
|
|
113
|
+
const privateKey = bytesToHex(privateKeyBytes);
|
|
114
|
+
wipeBytes(privateKeyBytes);
|
|
115
|
+
return { mnemonic: normalized, privateKey, publicKey: deriveSecp256k1PublicKey(privateKey) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A brand-new 12-word identity. Nothing is persisted. */
|
|
119
|
+
export function generateWebIdentity(): OpenedWebIdentity {
|
|
120
|
+
return deriveIdentityFromMnemonic(generateMnemonic(wordlist, 128));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A fresh random data key for a new envelope. */
|
|
124
|
+
export function generateDataKey(): Uint8Array {
|
|
125
|
+
const key = new Uint8Array(AEAD_KEY_LENGTH);
|
|
126
|
+
globalThis.crypto.getRandomValues(key);
|
|
127
|
+
return key;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The key-encryption key a passkey's PRF output yields for its own wrap. */
|
|
131
|
+
export function deriveKeyEncryptionKey(prfOutput: Uint8Array, credentialId: string): Uint8Array {
|
|
132
|
+
if (prfOutput.length !== PRF_OUTPUT_LENGTH) {
|
|
133
|
+
throw new Error(`PRF output must be ${PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
|
|
134
|
+
}
|
|
135
|
+
return hkdfSha256(prfOutput, KEK_SALT, utf8ToBytes(credentialId), AEAD_KEY_LENGTH);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Wrap `dataKey` for one passkey. */
|
|
139
|
+
export function wrapDataKey(
|
|
140
|
+
dataKey: Uint8Array,
|
|
141
|
+
prfOutput: Uint8Array,
|
|
142
|
+
credentialId: string,
|
|
143
|
+
publicKey: string,
|
|
144
|
+
now: Date = new Date(),
|
|
145
|
+
): WebIdentityWrap {
|
|
146
|
+
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
147
|
+
try {
|
|
148
|
+
const { nonce, ciphertext } = encryptAead(kek, dataKey, wrapAad(publicKey, credentialId));
|
|
149
|
+
return {
|
|
150
|
+
credentialId,
|
|
151
|
+
nonce: bytesToHex(nonce),
|
|
152
|
+
wrappedKey: bytesToHex(ciphertext),
|
|
153
|
+
createdAt: now.toISOString(),
|
|
154
|
+
};
|
|
155
|
+
} finally {
|
|
156
|
+
wipeBytes(kek);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Seal an identity into a new envelope that opens with the given passkey.
|
|
162
|
+
*
|
|
163
|
+
* Returns the data key too, so the caller can add further wraps in the same
|
|
164
|
+
* session; wipe it when done.
|
|
165
|
+
*/
|
|
166
|
+
export function sealWebIdentity(
|
|
167
|
+
identity: Pick<OpenedWebIdentity, 'mnemonic' | 'publicKey'>,
|
|
168
|
+
firstWrap: { prfOutput: Uint8Array; credentialId: string },
|
|
169
|
+
now: Date = new Date(),
|
|
170
|
+
): { envelope: WebIdentityEnvelope; dataKey: Uint8Array } {
|
|
171
|
+
const derived = deriveIdentityFromMnemonic(identity.mnemonic);
|
|
172
|
+
if (derived.publicKey !== identity.publicKey.toLowerCase()) {
|
|
173
|
+
throw new Error('The recovery phrase does not belong to this identity');
|
|
174
|
+
}
|
|
175
|
+
const dataKey = generateDataKey();
|
|
176
|
+
const entropy = mnemonicToEntropy(derived.mnemonic, wordlist);
|
|
177
|
+
try {
|
|
178
|
+
const { nonce, ciphertext } = encryptAead(dataKey, entropy, entropyAad(derived.publicKey));
|
|
179
|
+
const envelope: WebIdentityEnvelope = {
|
|
180
|
+
version: WEB_IDENTITY_ENVELOPE_VERSION,
|
|
181
|
+
algorithm: 'xchacha20poly1305',
|
|
182
|
+
publicKey: derived.publicKey,
|
|
183
|
+
entropyNonce: bytesToHex(nonce),
|
|
184
|
+
sealedEntropy: bytesToHex(ciphertext),
|
|
185
|
+
wraps: [wrapDataKey(dataKey, firstWrap.prfOutput, firstWrap.credentialId, derived.publicKey, now)],
|
|
186
|
+
};
|
|
187
|
+
return { envelope, dataKey };
|
|
188
|
+
} finally {
|
|
189
|
+
wipeBytes(entropy);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Recover the data key with one passkey's PRF output. */
|
|
194
|
+
export function unwrapDataKey(
|
|
195
|
+
envelope: WebIdentityEnvelope,
|
|
196
|
+
prfOutput: Uint8Array,
|
|
197
|
+
credentialId: string,
|
|
198
|
+
): Uint8Array {
|
|
199
|
+
const wrap = envelope.wraps.find((entry) => entry.credentialId === credentialId);
|
|
200
|
+
if (!wrap) {
|
|
201
|
+
throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
|
|
202
|
+
}
|
|
203
|
+
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
204
|
+
try {
|
|
205
|
+
return decryptAead(kek, hexToBytes(wrap.nonce), hexToBytes(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId));
|
|
206
|
+
} catch {
|
|
207
|
+
throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
|
|
208
|
+
} finally {
|
|
209
|
+
wipeBytes(kek);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Open an envelope with its data key.
|
|
215
|
+
*
|
|
216
|
+
* The derived public key is checked against the envelope's own: a sealed entropy
|
|
217
|
+
* that decrypts into a different identity is treated as corruption, never used.
|
|
218
|
+
*/
|
|
219
|
+
export function openWebIdentity(envelope: WebIdentityEnvelope, dataKey: Uint8Array): OpenedWebIdentity {
|
|
220
|
+
let entropy: Uint8Array;
|
|
221
|
+
try {
|
|
222
|
+
entropy = decryptAead(
|
|
223
|
+
dataKey,
|
|
224
|
+
hexToBytes(envelope.entropyNonce),
|
|
225
|
+
hexToBytes(envelope.sealedEntropy),
|
|
226
|
+
entropyAad(envelope.publicKey),
|
|
227
|
+
);
|
|
228
|
+
} catch {
|
|
229
|
+
throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
|
|
233
|
+
if (identity.publicKey !== envelope.publicKey.toLowerCase()) {
|
|
234
|
+
throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
|
|
235
|
+
}
|
|
236
|
+
return identity;
|
|
237
|
+
} finally {
|
|
238
|
+
wipeBytes(entropy);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Unwrap and open in one step. */
|
|
243
|
+
export function unlockWebIdentity(
|
|
244
|
+
envelope: WebIdentityEnvelope,
|
|
245
|
+
prfOutput: Uint8Array,
|
|
246
|
+
credentialId: string,
|
|
247
|
+
): OpenedWebIdentity {
|
|
248
|
+
const dataKey = unwrapDataKey(envelope, prfOutput, credentialId);
|
|
249
|
+
try {
|
|
250
|
+
return openWebIdentity(envelope, dataKey);
|
|
251
|
+
} finally {
|
|
252
|
+
wipeBytes(dataKey);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
|
|
257
|
+
export function addWrap(
|
|
258
|
+
envelope: WebIdentityEnvelope,
|
|
259
|
+
dataKey: Uint8Array,
|
|
260
|
+
prfOutput: Uint8Array,
|
|
261
|
+
credentialId: string,
|
|
262
|
+
now: Date = new Date(),
|
|
263
|
+
): WebIdentityEnvelope {
|
|
264
|
+
// Proves `dataKey` belongs to THIS envelope before anything new can open it.
|
|
265
|
+
openWebIdentity(envelope, dataKey);
|
|
266
|
+
const others = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
|
|
267
|
+
return {
|
|
268
|
+
...envelope,
|
|
269
|
+
wraps: [...others, wrapDataKey(dataKey, prfOutput, credentialId, envelope.publicKey, now)],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
|
|
274
|
+
export function removeWrap(envelope: WebIdentityEnvelope, credentialId: string): WebIdentityEnvelope {
|
|
275
|
+
const remaining = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
|
|
276
|
+
if (remaining.length === 0) {
|
|
277
|
+
throw new Error('An identity envelope must keep at least one passkey');
|
|
278
|
+
}
|
|
279
|
+
return { ...envelope, wraps: remaining };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The message an identity key signs to authorize an account action
|
|
284
|
+
* (`link_identity`, `web_envelope_delete`, …): `JSON.stringify({ action, userId,
|
|
285
|
+
* timestamp })`, byte-identical to what the API reconstructs.
|
|
286
|
+
*/
|
|
287
|
+
export function buildIdentityActionMessage(action: string, userId: string, timestamp: number): string {
|
|
288
|
+
return JSON.stringify({ action, userId, timestamp });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Sign an identity-action message with an opened identity's key. */
|
|
292
|
+
export async function signIdentityAction(
|
|
293
|
+
identity: Pick<OpenedWebIdentity, 'privateKey'>,
|
|
294
|
+
action: string,
|
|
295
|
+
userId: string,
|
|
296
|
+
timestamp: number = Date.now(),
|
|
297
|
+
): Promise<{ signature: string; timestamp: number }> {
|
|
298
|
+
const signature = await signMessage(buildIdentityActionMessage(action, userId, timestamp), identity.privateKey);
|
|
299
|
+
return { signature, timestamp };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The 6-digit short authentication string both sides of an identity transfer show.
|
|
304
|
+
*
|
|
305
|
+
* Bound to the pairing and to BOTH ephemeral public keys in their roles, so a relay
|
|
306
|
+
* that substitutes either key produces a different code on each screen. The user
|
|
307
|
+
* comparing the two codes is what makes the transfer safe against the relay itself.
|
|
308
|
+
*/
|
|
309
|
+
export function deriveTransferSas(input: {
|
|
310
|
+
pairingId: string;
|
|
311
|
+
initiatorEphemeralPublicKey: string;
|
|
312
|
+
responderEphemeralPublicKey: string;
|
|
313
|
+
}): string {
|
|
314
|
+
const digest = sha256(
|
|
315
|
+
utf8ToBytes(
|
|
316
|
+
JSON.stringify({
|
|
317
|
+
v: 'oxy-identity-transfer-sas-v1',
|
|
318
|
+
pairingId: input.pairingId.toLowerCase(),
|
|
319
|
+
initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
|
|
320
|
+
responder: input.responderEphemeralPublicKey.toLowerCase(),
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
|
|
325
|
+
return String(value % 1_000_000).padStart(6, '0');
|
|
326
|
+
}
|
|
@@ -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
|
}
|