Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5e17875
Add passkey authentication support for web wallet using WebAuthn PRF …
maycon-mello Apr 3, 2026
1628bdc
Simplify passkey API with unified passkey config and update documenta…
maycon-mello Apr 3, 2026
434184b
Fix prettier formatting
maycon-mello Apr 3, 2026
228cf64
Fix no-div-regex lint warning in base64url helper
maycon-mello Apr 3, 2026
ce8045a
Fix unit test expected error message for passkey support
maycon-mello Apr 3, 2026
78529c7
Add unit tests for passkey authentication
maycon-mello Apr 3, 2026
c9e06cd
Return prf: unknown from checkPasskeySupport since PRF can only be co…
maycon-mello Apr 8, 2026
0229607
Hash user.id with SHA-256 to comply with WebAuthn 64-byte limit
maycon-mello Apr 8, 2026
b843806
Use chunked encoding in credentialIdToBase64url to avoid stack overfl…
maycon-mello Apr 8, 2026
7f5d927
Remove prfSupported hard check during enrollment for Safari compatibi…
maycon-mello Apr 8, 2026
c84642f
Use stored identifier from localStorage and guard JSON.parse for robu…
maycon-mello Apr 8, 2026
b848816
Guard window.location access in resolvePasskeyOptions for non-browser…
maycon-mello Apr 8, 2026
8ba8921
Use placeholder values for credentials in example files
maycon-mello Apr 8, 2026
4c36a01
Clarify that PRF is a built-in WebAuthn capability, not a separate ex…
maycon-mello Apr 8, 2026
98a7b8c
Rename credentialId to passkeyCredentialId and move examples to examp…
maycon-mello Apr 8, 2026
11bf1bc
Merge branch 'master' into passkeys-web-wallet
maycon-mello Apr 8, 2026
001a380
Fix Slack reporter counting retries as separate failures
maycon-mello Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 130 additions & 2 deletions docs/cloud-wallet.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The Truvera Cloud Wallet service hosts individual wallets for each user. The use

Once initialized, the Cloud Wallet service automatically synchronizes documents between the EDV and the wallet application, allowing you to add, update, and remove credentials without dealing with the synchronization logic.

Each holder's individual cloud wallet is accessed using a key in the holder's possession. This key can be stored in the local storage of a wallet application, or derived from a biometric of the holder's. A recovery mnemonic can be used to recover a lost master key.
Each holder's individual cloud wallet is accessed using a key in the holder's possession. This key can be stored in the local storage of a wallet application, derived from a biometric of the holder's, or derived from a WebAuthn passkey using the PRF extension. A recovery mnemonic can be used to recover a lost master key.

## Usage example

Expand Down Expand Up @@ -191,7 +191,7 @@ The Truvera Cloud Wallet supports multiple authentication methods to unlock the

1. **Mnemonic-based authentication**: The traditional recovery phrase approach
2. **Biometric authentication**: Using fingerprints, facial recognition, or other biometric data
3. **Future extensions**: Can be extended to support passkeys and other authentication methods
3. **Passkey authentication**: Using WebAuthn passkeys with the PRF extension for browser-based wallets

### How multi-key authentication works

Expand Down Expand Up @@ -272,6 +272,122 @@ The authentication process:
4. Decrypts the master key
5. Uses the master key to access the CloudWalletVault

#### Passkey authentication

Passkeys provide a passwordless authentication method for web wallets using the WebAuthn PRF (Pseudo-Random Function) extension. The PRF extension extracts deterministic 32-byte key material from a passkey — same passkey + same salt always produces the same bytes. This key material is used to encrypt/decrypt the master key in the KeyMappingVault, following the same pattern as biometric authentication.

**Browser requirements**: Chrome 116+, Safari 18+ (macOS Sequoia / iOS 18), Edge 116+. The PRF extension is a built-in WebAuthn capability — no browser extensions or password manager add-ons are required. It is natively supported by platform authenticators (Touch ID, Windows Hello, Android biometrics) and synced passkey providers (iCloud Keychain, Google Password Manager).

##### Quick start with the web SDK

The simplest way to use passkeys is through the web SDK's `initialize` method with `passkey: true`. This handles enrollment, authentication, and localStorage management automatically:

```js
import TruveraWebWallet from '@docknetwork/wallet-sdk-web';

const wallet = await TruveraWebWallet.initialize({
edvUrl: EDV_URL,
edvAuthKey: EDV_AUTH_KEY,
networkId: 'testnet',
passkey: true,
});

// wallet.mnemonic is only present on first enrollment
if (wallet.mnemonic) {
// Prompt the user to save their recovery phrase
showRecoveryDialog(wallet.mnemonic);
}
```

On first visit this registers a passkey, generates a master key, encrypts it, and stores it in the vault. On return visits it authenticates with a single biometric/PIN prompt.

For more control, pass an options object:

```js
const wallet = await TruveraWebWallet.initialize({
edvUrl: EDV_URL,
edvAuthKey: EDV_AUTH_KEY,
networkId: 'testnet',
passkey: {
identifier: 'user@example.com', // Key derivation salt (defaults to hostname)
storageKey: 'my-app-passkey', // Custom localStorage key
rpName: 'My Application', // WebAuthn relying party name
},
});
```

##### Using the core SDK directly

For non-web environments or when you need full control over the WebAuthn ceremony, use the core SDK functions directly. These receive the PRF output as a parameter — the WebAuthn ceremony is handled separately.

**Step 1: Register a passkey and enroll**

```js
import {
checkPasskeySupport,
registerPasskey,
getPasskeyPRFKey,
enrollPasskey,
} from '@docknetwork/wallet-sdk-web';

// Option A: High-level — handles register + PRF + vault in one call
const { mnemonic, passkeyCredentialId } = await enrollPasskey({
edvUrl: EDV_URL,
edvAuthKey: EDV_AUTH_KEY,
identifier: 'user@example.com',
});

// Option B: Low-level — full control over each step
const support = await checkPasskeySupport();
const { credentialId, prfSupported } = await registerPasskey('user@example.com');
const { prfOutput } = await getPasskeyPRFKey('user@example.com', { credentialId });

import { enrollUserWithPasskey } from '@docknetwork/wallet-sdk-core/lib/cloud-wallet';
const { masterKey, mnemonic } = await enrollUserWithPasskey(
EDV_URL, EDV_AUTH_KEY, prfOutput, 'user@example.com'
);
```

**Step 2: Authenticate with passkey**

```js
import { authenticateWithPasskey } from '@docknetwork/wallet-sdk-core/lib/cloud-wallet';
import { getPasskeyPRFKey } from '@docknetwork/wallet-sdk-web';

const { prfOutput } = await getPasskeyPRFKey('user@example.com', { credentialId });

const masterKey = await authenticateWithPasskey(
EDV_URL, EDV_AUTH_KEY, prfOutput, 'user@example.com'
);
```

##### How it works

The passkey authentication process:
1. Performs a WebAuthn assertion with the PRF extension to extract deterministic key material
2. Derives vault access keys from the PRF output using HKDF (SHA-256, 32 bytes)
3. Initializes the KeyMappingVault and finds the encrypted master key
4. Derives the decryption key from the PRF output
5. Decrypts the master key using AES-GCM

##### Cross-device support

Passkeys sync automatically across devices via platform credential managers:
- **Apple**: iCloud Keychain syncs passkeys across all Apple devices with the same Apple ID
- **Google**: Google Password Manager syncs across Chrome on Android and desktop
- **Cross-platform**: QR code scanning enables cross-ecosystem authentication (e.g., using an iPhone passkey to authenticate on a Windows PC)

The `credentialId` can be omitted when calling `getPasskeyPRFKey` — the browser will show a passkey picker for discoverable credentials, enabling cross-device usage without pre-stored state.

##### Security considerations

- The `passkeyCredentialId` is a public identifier, safe to store in localStorage
- PRF output and derived encryption keys exist only in memory during a session — never persisted
- The PRF salt is deterministic (`SHA-256("truvera-wallet-prf-salt:" + identifier)`) and acts as a domain separator
- WebAuthn ceremonies require user interaction (biometric/PIN), providing natural rate limiting
- If a passkey is lost, the user can recover using their mnemonic phrase
- Passkeys synced via iCloud or Google Password Manager extend the security boundary to the sync provider

### Wallet recovery

This architecture allows solution developers to design the recovery mechanism that makes sense for your use case.
Expand All @@ -282,6 +398,18 @@ Alternatively, one or more recovery keys can be stored in the KeyMappingVault. A

If a biometrically derived key can no longer be generated, then a recovery key should be used to enroll a new biometric. Any biometric-bound credentials will need to be reissued with the new biometric.

For passkey-based wallets, the recovery mnemonic is returned during enrollment. If the passkey is lost (e.g., device replaced, credential deleted), the user can recover their wallet using the mnemonic and optionally enroll a new passkey:

```js
// Recover with mnemonic, then re-enroll with a new passkey
const wallet = await TruveraWebWallet.initialize({
edvUrl: EDV_URL,
edvAuthKey: EDV_AUTH_KEY,
networkId: 'testnet',
mnemonic: savedMnemonic,
});
```


## Organizational wallets

Expand Down
217 changes: 217 additions & 0 deletions packages/core/src/cloud-wallet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,52 @@ const {
generateCloudWalletMasterKey,
recoverCloudWalletMasterKey,
initializeCloudWallet,
derivePasskeyVaultKeys,
derivePasskeyEncryptionKey,
initializePasskeyKeyMappingVault,
enrollUserWithPasskey,
authenticateWithPasskey,
initializeCloudWalletWithPasskey,
PASSKEY_KEY_MAPPING_TYPE,
} = require('./cloud-wallet');

const mockInitializeFromMasterKey = jest.fn().mockResolvedValue(undefined);
const mockInitializeFromMnemonic = jest.fn().mockResolvedValue(undefined);
const mockFind = jest.fn().mockResolvedValue({documents: []});
const mockDeriveBiometricKey = jest.fn().mockReturnValue(Buffer.alloc(32));
const mockDeriveKeys = jest.fn().mockResolvedValue({
hmacKey: 'mock-hmac',
agreementKey: 'mock-agreement',
verificationKey: 'mock-verification',
});
const mockInitialize = jest.fn().mockResolvedValue(undefined);
const mockDeriveBiometricEncryptionKey = jest.fn().mockResolvedValue({
key: Buffer.alloc(32),
iv: Buffer.alloc(16),
});
const mockEncryptMasterKey = jest
.fn()
.mockResolvedValue(new Uint8Array([99, 99]));
const mockDecryptMasterKey = jest
.fn()
.mockResolvedValue(new Uint8Array([1, 2, 3]));
const mockGetController = jest.fn().mockResolvedValue('mock-controller');
const mockInsert = jest.fn().mockResolvedValue(undefined);

jest.mock('@docknetwork/wallet-sdk-wasm/src/services/edv', () => ({
edvService: {
initializeFromMasterKey: (...args) => mockInitializeFromMasterKey(...args),
initializeFromMnemonic: (...args) => mockInitializeFromMnemonic(...args),
find: (...args) => mockFind(...args),
deriveBiometricKey: (...args) => mockDeriveBiometricKey(...args),
deriveKeys: (...args) => mockDeriveKeys(...args),
initialize: (...args) => mockInitialize(...args),
deriveBiometricEncryptionKey: (...args) =>
mockDeriveBiometricEncryptionKey(...args),
encryptMasterKey: (...args) => mockEncryptMasterKey(...args),
decryptMasterKey: (...args) => mockDecryptMasterKey(...args),
getController: (...args) => mockGetController(...args),
insert: (...args) => mockInsert(...args),
},
}));

Expand Down Expand Up @@ -149,4 +184,186 @@ describe('cloud-wallet', () => {
).rejects.toThrow('Either masterKey or mnemonic is required');
});
});

describe('passkey functions', () => {
const edvUrl = 'https://edv.example.com';
const authKey = 'test-auth-key';
const prfOutput = new Uint8Array(32).fill(42);
const identifier = 'user@example.com';

describe('derivePasskeyVaultKeys', () => {
it('should derive vault keys from PRF output and identifier', async () => {
const result = await derivePasskeyVaultKeys(prfOutput, identifier);

expect(mockDeriveBiometricKey).toHaveBeenCalledWith(
expect.any(Buffer),
identifier,
);
expect(mockDeriveKeys).toHaveBeenCalled();
expect(result).toEqual({
hmacKey: 'mock-hmac',
agreementKey: 'mock-agreement',
verificationKey: 'mock-verification',
});
});

it('should convert prfOutput to Buffer before calling deriveBiometricKey', async () => {
await derivePasskeyVaultKeys(prfOutput, identifier);

const bufferArg = mockDeriveBiometricKey.mock.calls[0][0];
expect(Buffer.isBuffer(bufferArg)).toBe(true);
expect(bufferArg).toEqual(Buffer.from(prfOutput));
});
});

describe('derivePasskeyEncryptionKey', () => {
it('should derive encryption key from PRF output and identifier', async () => {
const result = await derivePasskeyEncryptionKey(prfOutput, identifier);

expect(mockDeriveBiometricEncryptionKey).toHaveBeenCalledWith(
expect.any(Buffer),
identifier,
);
expect(result).toHaveProperty('key');
expect(result).toHaveProperty('iv');
});
});

describe('initializePasskeyKeyMappingVault', () => {
it('should initialize the EDV with derived vault keys', async () => {
await initializePasskeyKeyMappingVault(
edvUrl,
authKey,
prfOutput,
identifier,
);

expect(mockInitialize).toHaveBeenCalledWith({
hmacKey: 'mock-hmac',
agreementKey: 'mock-agreement',
verificationKey: 'mock-verification',
edvUrl,
authKey,
});
});
});

describe('enrollUserWithPasskey', () => {
beforeEach(() => {
mockMnemonicGenerate.mockResolvedValue('mock mnemonic phrase');
mockMnemonicToMiniSecret.mockResolvedValue(
new Uint8Array([1, 2, 3, 4]),
);
});

it('should generate a master key and store it encrypted in the vault', async () => {
const result = await enrollUserWithPasskey(
edvUrl,
authKey,
prfOutput,
identifier,
);

expect(result.mnemonic).toBe('mock mnemonic phrase');
expect(result.masterKey).toEqual(new Uint8Array([1, 2, 3, 4]));
});

it('should encrypt the master key before storing', async () => {
await enrollUserWithPasskey(edvUrl, authKey, prfOutput, identifier);

expect(mockEncryptMasterKey).toHaveBeenCalledWith(
new Uint8Array([1, 2, 3, 4]),
expect.any(Buffer),
expect.any(Buffer),
);
});

it('should insert document with PASSKEY_KEY_MAPPING_TYPE', async () => {
await enrollUserWithPasskey(edvUrl, authKey, prfOutput, identifier);

expect(mockInsert).toHaveBeenCalledWith({
document: {
content: {
id: 'mock-controller#master-key',
type: PASSKEY_KEY_MAPPING_TYPE,
encryptedKey: expect.objectContaining({
data: expect.any(Array),
iv: expect.any(Array),
}),
},
},
});
});
});

describe('authenticateWithPasskey', () => {
it('should initialize vault and retrieve decrypted master key', async () => {
mockFind.mockResolvedValueOnce({
documents: [
{
content: {
id: 'mock-controller#master-key',
encryptedKey: {
data: [99, 99],
iv: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
},
},
],
});

const result = await authenticateWithPasskey(
edvUrl,
authKey,
prfOutput,
identifier,
);

expect(mockInitialize).toHaveBeenCalled();
expect(mockDecryptMasterKey).toHaveBeenCalled();
expect(result).toEqual(new Uint8Array([1, 2, 3]));
});

it('should throw when no key mapping document is found', async () => {
mockFind.mockResolvedValueOnce({documents: []});

await expect(
authenticateWithPasskey(edvUrl, authKey, prfOutput, identifier),
).rejects.toThrow('Authentication failed: Invalid identifier');
});
});

describe('initializeCloudWalletWithPasskey', () => {
it('should authenticate and initialize cloud wallet', async () => {
mockFind.mockResolvedValueOnce({
documents: [
{
content: {
id: 'mock-controller#master-key',
encryptedKey: {
data: [99, 99],
iv: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
},
},
],
});

const dataStore = createMockDataStore();
await initializeCloudWalletWithPasskey(
edvUrl,
authKey,
prfOutput,
identifier,
dataStore,
);

expect(mockInitializeFromMasterKey).toHaveBeenCalledWith({
masterKey: new Uint8Array([1, 2, 3]),
edvUrl,
authKey,
});
});
});
});
});
Loading
Loading