@1shotapi/ows-signer 0.2.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/README.md +106 -0
- package/html/index.html +54 -0
- package/package.json +37 -0
- package/src/constants.js +30 -0
- package/src/crypto/aes256.js +115 -0
- package/src/crypto/prf.js +119 -0
- package/src/crypto/recovery.js +81 -0
- package/src/crypto/sign.js +59 -0
- package/src/crypto/vendor/noble-ed25519.js +385 -0
- package/src/crypto/vendor/noble-secp256k1.js +602 -0
- package/src/debug.js +42 -0
- package/src/handlers.js +691 -0
- package/src/hex.js +68 -0
- package/src/main.js +40 -0
- package/src/rpc.js +48 -0
- package/src/state.js +96 -0
- package/src/ui.js +68 -0
- package/src/webauthn.js +150 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @1shotapi/ows-signer
|
|
2
|
+
|
|
3
|
+
The **OWS Signing Layer** — plain JavaScript, zero npm dependencies, no build step.
|
|
4
|
+
|
|
5
|
+
## Design
|
|
6
|
+
|
|
7
|
+
Minimal **KMS/HSM** iframe: WebAuthn PRF key custody, curve-based signing, optional export/recovery UI. Chain-specific marshalling lives in `@1shotapi/ows-signer-utils`.
|
|
8
|
+
|
|
9
|
+
## Deployment and rpId (hybrid)
|
|
10
|
+
|
|
11
|
+
- `rpId` is always **`window.location.hostname`** (the signer document origin).
|
|
12
|
+
- **Canonical CDN / on-chain gateway:** shared passkey namespace for all integrators on that host.
|
|
13
|
+
- **Per-brand rpId:** self-host or reverse-proxy signer HTML on your branding origin.
|
|
14
|
+
|
|
15
|
+
## Nesting
|
|
16
|
+
|
|
17
|
+
Must be embedded as **iframe inside a Branding Layer iframe** (not directly in the host). Rejects messages unless `window.parent !== window.top`.
|
|
18
|
+
|
|
19
|
+
## Wire protocol
|
|
20
|
+
|
|
21
|
+
Parent → signer:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{ "v": 1, "kind": "request", "method": "signDigest", "correlationId": "…", "params": { } }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Signer → parent:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{ "v": 1, "kind": "event", "event": "DigestSigned", "correlationId": "…", "data": { } }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Only accepts messages when `event.source === window.parent`. Replies use the parent's `event.origin` as `targetOrigin`.
|
|
34
|
+
|
|
35
|
+
## Methods
|
|
36
|
+
|
|
37
|
+
| Method | Params | Success events |
|
|
38
|
+
|--------|--------|----------------|
|
|
39
|
+
| `getVersion` | — | `Version` |
|
|
40
|
+
| `createCredential` | `name`, `options?` (`rpName`, `userDisplayName`, `userId`) | `KeyDerived`, `CredentialCreated` |
|
|
41
|
+
| `signDigest` | `digestData`, `scheme`, `credentialId?` | `KeyDerived` (PRF path), `DigestSigned` |
|
|
42
|
+
| `revealPrivateKey` | `credentialId?` | `KeyDerived` (+ DOM display) |
|
|
43
|
+
| `createRecoveryData` | `passwordText`, `buttonText`, `minPasswordLength`, `credentialId?` | `KeyDerived`, `RecoveryDataCreated` |
|
|
44
|
+
| `recoverKey` | `aes256EncryptedPrivateKey`, `passwordText`, `buttonText`, `credentialId?` | DOM display; `RecoverySessionStarted` or re-bind → `RecoverySessionCleared` |
|
|
45
|
+
| `getPublicKey` | `credentialId?`, `challenge?` | `KeyDerived`, `PublicKey`, `ChallengeSigned?` |
|
|
46
|
+
| `clearRecoverySession` | — | `RecoverySessionCleared` |
|
|
47
|
+
| `encryptAES256` | `plaintexts[]`, `credentialId?` | `KeyDerived` (PRF path), `AES256Encrypted` |
|
|
48
|
+
| `decryptAES256` | `ciphertexts[]`, `credentialId?` | `KeyDerived` (PRF path), `AES256Decrypted` |
|
|
49
|
+
|
|
50
|
+
Failure events: `NotAllowed`, `InvalidRequest`.
|
|
51
|
+
|
|
52
|
+
`createCredential` options:
|
|
53
|
+
|
|
54
|
+
- `rpName` — relying party display name in passkey UI (default `"OWS"`); does not affect `rpId`
|
|
55
|
+
- `userDisplayName` — friendly account label (defaults to `name`)
|
|
56
|
+
- `userId` — stable user handle bytes (base64); random if omitted
|
|
57
|
+
|
|
58
|
+
## Signing schemes
|
|
59
|
+
|
|
60
|
+
`secp256k1-ecdsa`, `secp256k1-ecdsa-recoverable`, `secp256k1-bip340` (not yet implemented), `ed25519`.
|
|
61
|
+
|
|
62
|
+
`digestData` is `0x`-prefixed hex. Hash schemes require 32 bytes.
|
|
63
|
+
|
|
64
|
+
## PRF labels
|
|
65
|
+
|
|
66
|
+
- `ows-v1/secp256k1`
|
|
67
|
+
- `ows-v1/ed25519` (derived via HKDF from PRF output)
|
|
68
|
+
- `ows-v1/aes256-gcm` (HKDF from the secp256k1 scalar — same material as `signDigest`)
|
|
69
|
+
|
|
70
|
+
## Recovery envelope
|
|
71
|
+
|
|
72
|
+
`ows1:0x…` — version byte, 16-byte salt, 12-byte IV, AES-GCM-256 ciphertext (PBKDF2-SHA256, 250k iterations).
|
|
73
|
+
|
|
74
|
+
## AES-256 seal envelope (credentials / general payload)
|
|
75
|
+
|
|
76
|
+
`ows-aes1:0x…` — version byte, 12-byte IV, AES-GCM-256 ciphertext+tag. Key is HKDF-SHA256 of the wallet secp256k1 scalar with info `ows-v1/aes256-gcm` (no PBKDF2). Works under a recovery session without a new WebAuthn ceremony. Batch methods amortize one ceremony across many plaintexts/ciphertexts.
|
|
77
|
+
|
|
78
|
+
## Recovery session
|
|
79
|
+
|
|
80
|
+
`recoverKey` caches the secp256k1 scalar in memory for subsequent `signDigest` / `encryptAES256` / `decryptAES256` calls without PRF. Cleared via `clearRecoverySession` or successful `credentialId` re-bind after recovery.
|
|
81
|
+
|
|
82
|
+
## Layout
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
html/index.html Entry document
|
|
86
|
+
src/main.js Bootstrap + message listener
|
|
87
|
+
src/handlers.js RPC dispatch
|
|
88
|
+
src/webauthn.js Passkey ceremonies
|
|
89
|
+
src/crypto/ PRF, signing, recovery
|
|
90
|
+
src/crypto/vendor/ Vendored @noble/secp256k1 + ed25519 (MIT)
|
|
91
|
+
reference/ 1ShotPay reference (not published)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## On-chain
|
|
95
|
+
|
|
96
|
+
See `@1shotapi/ows-onchain`.
|
|
97
|
+
|
|
98
|
+
## Integrity (planned)
|
|
99
|
+
|
|
100
|
+
Client-side confirmation that official signer bytes are loaded (signed manifest + verify-before-execute, optional EIP-8244 pin) is **specced and tabled** — see [docs/trusted-loader-plan.md](docs/trusted-loader-plan.md). Not implemented yet.
|
|
101
|
+
|
|
102
|
+
## Tests
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm test -w @1shotapi/ows-signer
|
|
106
|
+
```
|
package/html/index.html
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>OWS Custody Signer</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
font-family: system-ui, sans-serif;
|
|
11
|
+
}
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 8px;
|
|
15
|
+
}
|
|
16
|
+
.ows-signer-ui {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
gap: 8px;
|
|
20
|
+
max-width: 100%;
|
|
21
|
+
}
|
|
22
|
+
.ows-key {
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding: 8px;
|
|
25
|
+
border: 1px solid #8884;
|
|
26
|
+
border-radius: 4px;
|
|
27
|
+
font-family: ui-monospace, monospace;
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
word-break: break-all;
|
|
30
|
+
white-space: pre-wrap;
|
|
31
|
+
}
|
|
32
|
+
.ows-password {
|
|
33
|
+
padding: 8px;
|
|
34
|
+
border: 1px solid #8884;
|
|
35
|
+
border-radius: 4px;
|
|
36
|
+
font: inherit;
|
|
37
|
+
}
|
|
38
|
+
.ows-copy,
|
|
39
|
+
.ows-confirm {
|
|
40
|
+
padding: 8px 12px;
|
|
41
|
+
border: 1px solid #8884;
|
|
42
|
+
border-radius: 4px;
|
|
43
|
+
background: transparent;
|
|
44
|
+
font: inherit;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
align-self: flex-start;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div id="ows-signer-root"></div>
|
|
52
|
+
<script type="module" src="../src/main.js"></script>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@1shotapi/ows-signer",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OWS Signing Layer — plain JavaScript WebAuthn PRF key custody with curve-based signing (on-chain via EIP-8244)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"html",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
"./html": "./html/index.html",
|
|
13
|
+
"./src/*": "./src/*"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/*.test.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"ows",
|
|
20
|
+
"open-wallet-standard",
|
|
21
|
+
"webauthn",
|
|
22
|
+
"prf",
|
|
23
|
+
"passkey",
|
|
24
|
+
"signer",
|
|
25
|
+
"custody",
|
|
26
|
+
"secp256k1"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/1Shot-API/open-wallet.git",
|
|
31
|
+
"directory": "packages/ows-signer"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** @typedef {'secp256k1-ecdsa' | 'secp256k1-ecdsa-recoverable' | 'secp256k1-bip340' | 'ed25519'} SignScheme */
|
|
2
|
+
|
|
3
|
+
export const API_VERSION = 1;
|
|
4
|
+
export const SIGNER_VERSION = "0.1.0";
|
|
5
|
+
|
|
6
|
+
export const PRF_LABEL_SECP256K1 = new TextEncoder().encode("ows-v1/secp256k1");
|
|
7
|
+
export const PRF_LABEL_ED25519 = new TextEncoder().encode("ows-v1/ed25519");
|
|
8
|
+
/** HKDF info for AES-256-GCM — IKM is the secp256k1 scalar (`signDigest` material). */
|
|
9
|
+
export const PRF_LABEL_AES256 = new TextEncoder().encode("ows-v1/aes256-gcm");
|
|
10
|
+
|
|
11
|
+
/** @type {SignScheme[]} */
|
|
12
|
+
export const SIGN_SCHEMES = [
|
|
13
|
+
"secp256k1-ecdsa",
|
|
14
|
+
"secp256k1-ecdsa-recoverable",
|
|
15
|
+
"secp256k1-bip340",
|
|
16
|
+
"ed25519",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export const METHODS = [
|
|
20
|
+
"getVersion",
|
|
21
|
+
"createCredential",
|
|
22
|
+
"signDigest",
|
|
23
|
+
"revealPrivateKey",
|
|
24
|
+
"createRecoveryData",
|
|
25
|
+
"recoverKey",
|
|
26
|
+
"getPublicKey",
|
|
27
|
+
"clearRecoverySession",
|
|
28
|
+
"encryptAES256",
|
|
29
|
+
"decryptAES256",
|
|
30
|
+
];
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { PRF_LABEL_AES256 } from "../constants.js";
|
|
2
|
+
import { parse0xHex, to0xHex } from "../hex.js";
|
|
3
|
+
import { hkdfExpand } from "./prf.js";
|
|
4
|
+
|
|
5
|
+
/** Envelope prefix — distinct from recovery `ows1:` (PBKDF2). */
|
|
6
|
+
export const AES256_ENVELOPE_PREFIX = "ows-aes1:";
|
|
7
|
+
const AES256_VERSION = 1;
|
|
8
|
+
const IV_LENGTH = 12;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Derive an AES-256-GCM CryptoKey from the wallet secp256k1 scalar
|
|
12
|
+
* (same material `signDigest` uses), via HKDF domain separation.
|
|
13
|
+
*
|
|
14
|
+
* @param {Uint8Array} secp256k1PrivateKey
|
|
15
|
+
* @returns {Promise<CryptoKey>}
|
|
16
|
+
*/
|
|
17
|
+
export async function deriveAes256KeyFromSecp256k1(secp256k1PrivateKey) {
|
|
18
|
+
const raw = await hkdfExpand(
|
|
19
|
+
secp256k1PrivateKey.buffer.slice(
|
|
20
|
+
secp256k1PrivateKey.byteOffset,
|
|
21
|
+
secp256k1PrivateKey.byteOffset + secp256k1PrivateKey.byteLength,
|
|
22
|
+
),
|
|
23
|
+
PRF_LABEL_AES256,
|
|
24
|
+
32,
|
|
25
|
+
);
|
|
26
|
+
try {
|
|
27
|
+
return await crypto.subtle.importKey(
|
|
28
|
+
"raw",
|
|
29
|
+
raw,
|
|
30
|
+
{ name: "AES-GCM", length: 256 },
|
|
31
|
+
false,
|
|
32
|
+
["encrypt", "decrypt"],
|
|
33
|
+
);
|
|
34
|
+
} finally {
|
|
35
|
+
raw.fill(0);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Encrypt a UTF-8 plaintext. Envelope: `ows-aes1:0x` ‖ version ‖ IV ‖ ciphertext+tag.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} plaintext
|
|
43
|
+
* @param {CryptoKey} aesKey
|
|
44
|
+
* @returns {Promise<string>}
|
|
45
|
+
*/
|
|
46
|
+
export async function encryptAes256String(plaintext, aesKey) {
|
|
47
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
48
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
49
|
+
const ciphertext = new Uint8Array(
|
|
50
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded),
|
|
51
|
+
);
|
|
52
|
+
const payload = new Uint8Array(1 + IV_LENGTH + ciphertext.length);
|
|
53
|
+
payload[0] = AES256_VERSION;
|
|
54
|
+
payload.set(iv, 1);
|
|
55
|
+
payload.set(ciphertext, 1 + IV_LENGTH);
|
|
56
|
+
return `${AES256_ENVELOPE_PREFIX}${to0xHex(payload)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decrypt an `ows-aes1:` envelope to a UTF-8 string.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} envelope
|
|
63
|
+
* @param {CryptoKey} aesKey
|
|
64
|
+
* @returns {Promise<string>}
|
|
65
|
+
*/
|
|
66
|
+
export async function decryptAes256String(envelope, aesKey) {
|
|
67
|
+
if (!envelope.startsWith(AES256_ENVELOPE_PREFIX)) {
|
|
68
|
+
throw new Error("invalidAes256Envelope");
|
|
69
|
+
}
|
|
70
|
+
const bytes = parse0xHex(envelope.slice(AES256_ENVELOPE_PREFIX.length));
|
|
71
|
+
if (bytes.length < 1 + IV_LENGTH + 16) {
|
|
72
|
+
throw new Error("invalidAes256Envelope");
|
|
73
|
+
}
|
|
74
|
+
if (bytes[0] !== AES256_VERSION) {
|
|
75
|
+
throw new Error("unsupportedAes256Version");
|
|
76
|
+
}
|
|
77
|
+
const iv = bytes.slice(1, 1 + IV_LENGTH);
|
|
78
|
+
const ciphertext = bytes.slice(1 + IV_LENGTH);
|
|
79
|
+
try {
|
|
80
|
+
const plain = new Uint8Array(
|
|
81
|
+
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, ciphertext),
|
|
82
|
+
);
|
|
83
|
+
return new TextDecoder().decode(plain);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error("decryptionFailed");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string[]} plaintexts
|
|
91
|
+
* @param {Uint8Array} secp256k1PrivateKey
|
|
92
|
+
* @returns {Promise<string[]>}
|
|
93
|
+
*/
|
|
94
|
+
export async function encryptAes256Batch(plaintexts, secp256k1PrivateKey) {
|
|
95
|
+
const aesKey = await deriveAes256KeyFromSecp256k1(secp256k1PrivateKey);
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const plaintext of plaintexts) {
|
|
98
|
+
out.push(await encryptAes256String(plaintext, aesKey));
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string[]} envelopes
|
|
105
|
+
* @param {Uint8Array} secp256k1PrivateKey
|
|
106
|
+
* @returns {Promise<string[]>}
|
|
107
|
+
*/
|
|
108
|
+
export async function decryptAes256Batch(envelopes, secp256k1PrivateKey) {
|
|
109
|
+
const aesKey = await deriveAes256KeyFromSecp256k1(secp256k1PrivateKey);
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const envelope of envelopes) {
|
|
112
|
+
out.push(await decryptAes256String(envelope, aesKey));
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { PRF_LABEL_ED25519, PRF_LABEL_SECP256K1 } from "../constants.js";
|
|
2
|
+
import { getPublicKeyAsync as edGetPublicKeyAsync } from "./vendor/noble-ed25519.js";
|
|
3
|
+
import { getPublicKey as secpGetPublicKey } from "./vendor/noble-secp256k1.js";
|
|
4
|
+
|
|
5
|
+
const SECP256K1_N = BigInt(
|
|
6
|
+
"0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {unknown} prfOutput
|
|
11
|
+
* @returns {ArrayBuffer | null}
|
|
12
|
+
*/
|
|
13
|
+
export function normalizePrfOutputToArrayBuffer(prfOutput) {
|
|
14
|
+
if (prfOutput instanceof ArrayBuffer) return prfOutput;
|
|
15
|
+
if (prfOutput instanceof Uint8Array) {
|
|
16
|
+
return prfOutput.buffer.slice(
|
|
17
|
+
prfOutput.byteOffset,
|
|
18
|
+
prfOutput.byteOffset + prfOutput.byteLength,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
if (ArrayBuffer.isView(prfOutput)) {
|
|
22
|
+
const view = /** @type {ArrayBufferView} */ (prfOutput);
|
|
23
|
+
return view.buffer.slice(
|
|
24
|
+
view.byteOffset,
|
|
25
|
+
view.byteOffset + view.byteLength,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(prfOutput)) {
|
|
29
|
+
return new Uint8Array(prfOutput).buffer;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {ArrayBuffer} prfBuffer
|
|
36
|
+
* @param {Uint8Array} infoLabel
|
|
37
|
+
* @param {number} length
|
|
38
|
+
* @returns {Promise<Uint8Array>}
|
|
39
|
+
*/
|
|
40
|
+
export async function hkdfExpand(prfBuffer, infoLabel, length) {
|
|
41
|
+
const ikm = new Uint8Array(prfBuffer);
|
|
42
|
+
const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, [
|
|
43
|
+
"deriveBits",
|
|
44
|
+
]);
|
|
45
|
+
const salt = new Uint8Array(32);
|
|
46
|
+
const bits = await crypto.subtle.deriveBits(
|
|
47
|
+
{
|
|
48
|
+
name: "HKDF",
|
|
49
|
+
hash: "SHA-256",
|
|
50
|
+
salt,
|
|
51
|
+
info: infoLabel,
|
|
52
|
+
},
|
|
53
|
+
baseKey,
|
|
54
|
+
length * 8,
|
|
55
|
+
);
|
|
56
|
+
return new Uint8Array(bits);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {ArrayBuffer} prfBuffer
|
|
61
|
+
* @param {Uint8Array} infoLabel
|
|
62
|
+
* @returns {Promise<Uint8Array>}
|
|
63
|
+
*/
|
|
64
|
+
export async function deriveScalarFromPrf(prfBuffer, infoLabel) {
|
|
65
|
+
for (let counter = 0; counter < 255; counter++) {
|
|
66
|
+
const info = new Uint8Array(infoLabel.length + 1);
|
|
67
|
+
info.set(infoLabel);
|
|
68
|
+
info[infoLabel.length] = counter;
|
|
69
|
+
const candidate = await hkdfExpand(prfBuffer, info, 32);
|
|
70
|
+
const scalar = bytesToBigInt(candidate);
|
|
71
|
+
if (scalar > 0n && scalar < SECP256K1_N) {
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw new Error("Failed to derive valid secp256k1 scalar from PRF");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {ArrayBuffer} prfBuffer
|
|
80
|
+
* @returns {Promise<Uint8Array>}
|
|
81
|
+
*/
|
|
82
|
+
export async function deriveEd25519SeedFromPrf(prfBuffer) {
|
|
83
|
+
return hkdfExpand(prfBuffer, PRF_LABEL_ED25519, 32);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {PublicKeyCredential} credential
|
|
88
|
+
* @returns {Promise<{ secp256k1PrivateKey: Uint8Array, secp256k1PublicKey: Uint8Array, ed25519PublicKey: Uint8Array }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function deriveKeysFromCredential(credential) {
|
|
91
|
+
const rawPrf = /** @type {{ prf?: { results?: { first?: unknown } } }} */ (
|
|
92
|
+
credential.getClientExtensionResults()
|
|
93
|
+
).prf?.results?.first;
|
|
94
|
+
const prfBuffer = normalizePrfOutputToArrayBuffer(rawPrf);
|
|
95
|
+
if (!prfBuffer) {
|
|
96
|
+
throw new Error("Passkey does not support PRF or PRF output missing");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const secp256k1PrivateKey = await deriveScalarFromPrf(
|
|
100
|
+
prfBuffer,
|
|
101
|
+
PRF_LABEL_SECP256K1,
|
|
102
|
+
);
|
|
103
|
+
const ed25519Seed = await deriveEd25519SeedFromPrf(prfBuffer);
|
|
104
|
+
// Uncompressed (0x04 ‖ X ‖ Y) — required by viem `publicKeyToAddress`.
|
|
105
|
+
const secp256k1PublicKey = secpGetPublicKey(secp256k1PrivateKey, false);
|
|
106
|
+
const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
|
|
107
|
+
|
|
108
|
+
return { secp256k1PrivateKey, secp256k1PublicKey, ed25519PublicKey };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {Uint8Array} bytes
|
|
113
|
+
* @returns {bigint}
|
|
114
|
+
*/
|
|
115
|
+
function bytesToBigInt(bytes) {
|
|
116
|
+
let result = 0n;
|
|
117
|
+
for (const b of bytes) result = (result << 8n) + BigInt(b);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parse0xHex, to0xHex } from "../hex.js";
|
|
2
|
+
|
|
3
|
+
const RECOVERY_VERSION = 1;
|
|
4
|
+
const PBKDF2_ITERATIONS = 250_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} passphrase
|
|
8
|
+
* @param {Uint8Array} salt
|
|
9
|
+
* @returns {Promise<CryptoKey>}
|
|
10
|
+
*/
|
|
11
|
+
async function deriveAesKey(passphrase, salt) {
|
|
12
|
+
const enc = new TextEncoder();
|
|
13
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
14
|
+
"raw",
|
|
15
|
+
enc.encode(passphrase),
|
|
16
|
+
"PBKDF2",
|
|
17
|
+
false,
|
|
18
|
+
["deriveKey"],
|
|
19
|
+
);
|
|
20
|
+
return crypto.subtle.deriveKey(
|
|
21
|
+
{
|
|
22
|
+
name: "PBKDF2",
|
|
23
|
+
salt,
|
|
24
|
+
iterations: PBKDF2_ITERATIONS,
|
|
25
|
+
hash: "SHA-256",
|
|
26
|
+
},
|
|
27
|
+
keyMaterial,
|
|
28
|
+
{ name: "AES-GCM", length: 256 },
|
|
29
|
+
false,
|
|
30
|
+
["encrypt", "decrypt"],
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Uint8Array} privateKey
|
|
36
|
+
* @param {string} passphrase
|
|
37
|
+
* @returns {Promise<string>}
|
|
38
|
+
*/
|
|
39
|
+
export async function encryptPrivateKey(privateKey, passphrase) {
|
|
40
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
41
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
42
|
+
const aesKey = await deriveAesKey(passphrase, salt);
|
|
43
|
+
const ciphertext = new Uint8Array(
|
|
44
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, privateKey),
|
|
45
|
+
);
|
|
46
|
+
const payload = new Uint8Array(
|
|
47
|
+
1 + salt.length + iv.length + ciphertext.length,
|
|
48
|
+
);
|
|
49
|
+
payload[0] = RECOVERY_VERSION;
|
|
50
|
+
payload.set(salt, 1);
|
|
51
|
+
payload.set(iv, 1 + salt.length);
|
|
52
|
+
payload.set(ciphertext, 1 + salt.length + iv.length);
|
|
53
|
+
return `ows1:${to0xHex(payload)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} envelope
|
|
58
|
+
* @returns {Promise<Uint8Array>}
|
|
59
|
+
*/
|
|
60
|
+
export async function decryptPrivateKey(envelope, passphrase) {
|
|
61
|
+
if (!envelope.startsWith("ows1:")) {
|
|
62
|
+
throw new Error("invalidRecoveryEnvelope");
|
|
63
|
+
}
|
|
64
|
+
const bytes = parse0xHex(envelope.slice(5));
|
|
65
|
+
if (bytes[0] !== RECOVERY_VERSION) {
|
|
66
|
+
throw new Error("unsupportedRecoveryVersion");
|
|
67
|
+
}
|
|
68
|
+
const salt = bytes.slice(1, 17);
|
|
69
|
+
const iv = bytes.slice(17, 29);
|
|
70
|
+
const ciphertext = bytes.slice(29);
|
|
71
|
+
const aesKey = await deriveAesKey(passphrase, salt);
|
|
72
|
+
try {
|
|
73
|
+
const plain = new Uint8Array(
|
|
74
|
+
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, ciphertext),
|
|
75
|
+
);
|
|
76
|
+
if (plain.length !== 32) throw new Error("invalidDecryptedKey");
|
|
77
|
+
return plain;
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error("decryptionFailed");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { to0xHex } from "../hex.js";
|
|
2
|
+
import { signAsync as edSignAsync } from "./vendor/noble-ed25519.js";
|
|
3
|
+
import { signAsync as secpSignAsync } from "./vendor/noble-secp256k1.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {import('../constants.js').SignScheme} scheme
|
|
7
|
+
* @param {Uint8Array} digest
|
|
8
|
+
* @param {Uint8Array} secp256k1PrivateKey
|
|
9
|
+
* @param {Uint8Array} ed25519Seed
|
|
10
|
+
* @returns {Promise<`0x${string}`>}
|
|
11
|
+
*/
|
|
12
|
+
export async function signWithScheme(
|
|
13
|
+
scheme,
|
|
14
|
+
digest,
|
|
15
|
+
secp256k1PrivateKey,
|
|
16
|
+
ed25519Seed,
|
|
17
|
+
) {
|
|
18
|
+
switch (scheme) {
|
|
19
|
+
case "secp256k1-ecdsa": {
|
|
20
|
+
const sig = await secpSignAsync(digest, secp256k1PrivateKey, {
|
|
21
|
+
lowS: true,
|
|
22
|
+
});
|
|
23
|
+
return to0xHex(sig.toCompactRawBytes());
|
|
24
|
+
}
|
|
25
|
+
case "secp256k1-ecdsa-recoverable": {
|
|
26
|
+
const sig = await secpSignAsync(digest, secp256k1PrivateKey, {
|
|
27
|
+
lowS: true,
|
|
28
|
+
});
|
|
29
|
+
const compact = sig.toCompactRawBytes();
|
|
30
|
+
const recovery = sig.recovery ?? 0;
|
|
31
|
+
const out = new Uint8Array(65);
|
|
32
|
+
out.set(compact, 0);
|
|
33
|
+
out[64] = recovery;
|
|
34
|
+
return to0xHex(out);
|
|
35
|
+
}
|
|
36
|
+
case "secp256k1-bip340":
|
|
37
|
+
throw new Error("schemeNotImplemented");
|
|
38
|
+
case "ed25519": {
|
|
39
|
+
const sig = await edSignAsync(digest, ed25519Seed);
|
|
40
|
+
return to0xHex(sig);
|
|
41
|
+
}
|
|
42
|
+
default:
|
|
43
|
+
throw new Error("unknownScheme");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {import('../constants.js').SignScheme} scheme
|
|
49
|
+
* @param {Uint8Array} payload
|
|
50
|
+
*/
|
|
51
|
+
export function validateSignPayload(scheme, payload) {
|
|
52
|
+
if (scheme === "ed25519") {
|
|
53
|
+
if (payload.length === 0) throw new Error("invalidPayload");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (payload.length !== 32) {
|
|
57
|
+
throw new Error("digestMustBe32Bytes");
|
|
58
|
+
}
|
|
59
|
+
}
|