@1shotapi/ows-signer 0.3.1 → 0.3.3

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 CHANGED
@@ -41,6 +41,7 @@ Only accepts messages when `event.source === window.parent`. Replies use the par
41
41
  | `signDigest` | `digests[]`, `credentialId?`, ceremony UI? | `KeyDerived` (PRF path), `DigestSigned` (`results[]`) |
42
42
  | `executeBatch` | batch fields + ceremony UI? | `KeyDerived` (when keys derived), `BatchExecuted` |
43
43
  | `revealPrivateKey` | `credentialId?`, ceremony UI? | `KeyDerived`, then `PrivateKeyRevealed` (after user dismisses DOM key UI) |
44
+ | `importPrivateKey` | — | DOM hex input; `KeyDerived`, `RecoverySessionStarted` |
44
45
  | `createRecoveryData` | passphrase fields + ceremony UI? | `KeyDerived`, `RecoveryDataCreated` |
45
46
  | `recoverKey` | envelope + passphrase fields + ceremony UI? | DOM display; `RecoverySessionStarted` or re-bind → `RecoverySessionCleared` |
46
47
  | `getPublicKey` | `credentialId?`, `challenge?`, ceremony UI? | `KeyDerived`, `PublicKey`, `ChallengeSigned?` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1shotapi/ows-signer",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "OWS Signing Layer — plain JavaScript WebAuthn PRF key custody with curve-based signing (on-chain via EIP-8244)",
5
5
  "type": "module",
6
6
  "files": [
package/src/constants.js CHANGED
@@ -21,6 +21,7 @@ export const METHODS = [
21
21
  "createCredential",
22
22
  "signDigest",
23
23
  "revealPrivateKey",
24
+ "importPrivateKey",
24
25
  "createRecoveryData",
25
26
  "recoverKey",
26
27
  "getPublicKey",
package/src/handlers.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  normalizePrfOutputToArrayBuffer,
17
17
  } from "./crypto/prf.js";
18
18
  import { getPublicKeyAsync as edGetPublicKeyAsync } from "./crypto/vendor/noble-ed25519.js";
19
- import { getPublicKey as secpGetPublicKey } from "./crypto/vendor/noble-secp256k1.js";
19
+ import { getPublicKey as secpGetPublicKey, utils as secpUtils } from "./crypto/vendor/noble-secp256k1.js";
20
20
  import { debugLog, describePrfExtensionResults } from "./debug.js";
21
21
  import { decryptPrivateKey, encryptPrivateKey } from "./crypto/recovery.js";
22
22
  import { signWithScheme, validateSignPayload } from "./crypto/sign.js";
@@ -30,7 +30,15 @@ import {
30
30
  withCeremony,
31
31
  zeroize,
32
32
  } from "./state.js";
33
- import { clearUi, CeremonyDeniedError, promptCeremonyConfirm, promptPassphrase, showPrivateKey } from "./ui.js";
33
+ import {
34
+ clearUi,
35
+ CeremonyDeniedError,
36
+ promptCeremonyConfirm,
37
+ promptPassphrase,
38
+ promptPrivateKey,
39
+ showPrivateKey,
40
+ updateCeremonyCopy,
41
+ } from "./ui.js";
34
42
  import {
35
43
  createPasskeyCredential,
36
44
  getAssertionSignatureBase64Url,
@@ -126,6 +134,10 @@ export async function handleRequest(
126
134
  await handleRevealPrivateKey(params, correlationId, targetOrigin);
127
135
  return;
128
136
 
137
+ case "importPrivateKey":
138
+ await handleImportPrivateKey(params, correlationId, targetOrigin);
139
+ return;
140
+
129
141
  case "createRecoveryData":
130
142
  await handleCreateRecoveryData(params, correlationId, targetOrigin);
131
143
  return;
@@ -191,9 +203,14 @@ function ceremonyFieldsFromParams(params) {
191
203
  *
192
204
  * @param {PublicKeyCredential} credential
193
205
  * @param {string} [credentialId]
206
+ * @param {(() => void) | undefined} [onBeforeFollowUpGet]
194
207
  * @returns {Promise<PublicKeyCredential>}
195
208
  */
196
- async function credentialForKeyDerivation(credential, credentialId) {
209
+ async function credentialForKeyDerivation(
210
+ credential,
211
+ credentialId,
212
+ onBeforeFollowUpGet,
213
+ ) {
197
214
  debugLog("ceremony extension results", describePrfExtensionResults(credential));
198
215
 
199
216
  const rawPrf = credential.getClientExtensionResults()?.prf?.results?.first;
@@ -206,7 +223,11 @@ async function credentialForKeyDerivation(credential, credentialId) {
206
223
  debugLog("no PRF results on registration; running assertion with prf.eval", {
207
224
  credentialId: id,
208
225
  });
209
- // Same RPC — no second Confirm (one screen per call).
226
+ if (typeof onBeforeFollowUpGet === "function") {
227
+ onBeforeFollowUpGet();
228
+ }
229
+ // Prefer keeping the parent Confirm panel open (call from runOnConfirm) so
230
+ // the user still sees why this second WebAuthn prompt appears.
210
231
  const assertion = await getPasskeyAssertion(undefined, id);
211
232
  debugLog(
212
233
  "assertion extension results",
@@ -234,14 +255,31 @@ async function handleCreateCredential(params, correlationId, targetOrigin) {
234
255
  : {};
235
256
 
236
257
  await withCeremony(async () => {
237
- const credential = await promptCeremonyConfirm(options, () =>
238
- createPasskeyCredential(name, options),
239
- );
240
- const credentialId = getCredentialId(credential);
241
- const prfCredential = await credentialForKeyDerivation(
242
- credential,
243
- credentialId,
244
- );
258
+ // Create + optional PRF follow-up get stay under one Confirm panel so the
259
+ // explanation remains visible for every WebAuthn prompt in this RPC.
260
+ /** @type {PublicKeyCredential | undefined} */
261
+ let registrationCredential;
262
+ const prfCredential = await promptCeremonyConfirm(options, async () => {
263
+ registrationCredential = await createPasskeyCredential(name, options);
264
+ const credentialId = getCredentialId(registrationCredential);
265
+ return credentialForKeyDerivation(
266
+ registrationCredential,
267
+ credentialId,
268
+ () => {
269
+ updateCeremonyCopy({
270
+ explanationHeader: "Unlock your new passkey",
271
+ explanationText:
272
+ "Confirm with your passkey again to finish setting up this wallet.",
273
+ confirmButtonText: options.confirmButtonText,
274
+ denyButtonText: options.denyButtonText,
275
+ });
276
+ },
277
+ );
278
+ });
279
+ if (!registrationCredential) {
280
+ throw new Error("createCredential: registration credential missing");
281
+ }
282
+ const credentialId = getCredentialId(registrationCredential);
245
283
  const keys = await deriveKeysFromCredential(prfCredential);
246
284
  emitKeyDerived(
247
285
  targetOrigin,
@@ -252,7 +290,7 @@ async function handleCreateCredential(params, correlationId, targetOrigin) {
252
290
 
253
291
  emitEvent(window.parent, targetOrigin, "CredentialCreated", correlationId, {
254
292
  credentialId,
255
- cosePublicKey: getCosePublicKeyBase64Url(credential),
293
+ cosePublicKey: getCosePublicKeyBase64Url(registrationCredential),
256
294
  secp256k1PublicKey: to0xHex(keys.secp256k1PublicKey),
257
295
  });
258
296
  zeroize(keys.secp256k1PrivateKey);
@@ -462,6 +500,64 @@ async function handleRevealPrivateKey(params, correlationId, targetOrigin) {
462
500
  );
463
501
  }
464
502
 
503
+ /**
504
+ * Paste a secp256k1 private key hex into the Signing Layer and start a recovery
505
+ * session (same session semantics as `recoverKey` without a backup envelope).
506
+ *
507
+ * @param {Record<string, unknown>} _params
508
+ * @param {string | undefined} correlationId
509
+ * @param {string} targetOrigin
510
+ */
511
+ async function handleImportPrivateKey(_params, correlationId, targetOrigin) {
512
+ const raw = await promptPrivateKey();
513
+ let privateKey;
514
+ try {
515
+ privateKey = parseImportedPrivateKeyHex(raw);
516
+ } catch {
517
+ emitInvalid(correlationId, targetOrigin, "invalidPrivateKey");
518
+ return;
519
+ }
520
+ if (!secpUtils.isValidPrivateKey(privateKey)) {
521
+ zeroize(privateKey);
522
+ emitInvalid(correlationId, targetOrigin, "invalidPrivateKey");
523
+ return;
524
+ }
525
+
526
+ setRecoveryPrivateKey(privateKey);
527
+
528
+ const secp256k1PublicKey = secpGetPublicKey(privateKey, false);
529
+ const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(privateKey);
530
+ const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
531
+ emitKeyDerived(
532
+ targetOrigin,
533
+ correlationId,
534
+ secp256k1PublicKey,
535
+ ed25519PublicKey,
536
+ );
537
+ zeroize(ed25519Seed);
538
+
539
+ emitEvent(
540
+ window.parent,
541
+ targetOrigin,
542
+ "RecoverySessionStarted",
543
+ correlationId,
544
+ { recoverySessionActive: true },
545
+ );
546
+ }
547
+
548
+ /**
549
+ * @param {string} raw
550
+ * @returns {Uint8Array}
551
+ */
552
+ function parseImportedPrivateKeyHex(raw) {
553
+ const trimmed = raw.trim();
554
+ const withPrefix =
555
+ trimmed.startsWith("0x") || trimmed.startsWith("0X")
556
+ ? trimmed
557
+ : `0x${trimmed}`;
558
+ return parse0xHex(withPrefix, 32);
559
+ }
560
+
465
561
  /**
466
562
  * @param {Record<string, unknown>} params
467
563
  * @param {string | undefined} correlationId
package/src/ui.js CHANGED
@@ -131,6 +131,22 @@ export function resolveCeremonyUi(fields) {
131
131
  };
132
132
  }
133
133
 
134
+ /**
135
+ * Update header/body on an open ceremony Confirm panel (e.g. before a follow-up
136
+ * WebAuthn get). No-op if the ceremony UI is not showing.
137
+ *
138
+ * @param {CeremonyUiFields | Record<string, unknown> | undefined} fields
139
+ */
140
+ export function updateCeremonyCopy(fields) {
141
+ if (!root) return;
142
+ const header = root.querySelector(".ows-ceremony-header");
143
+ const text = root.querySelector(".ows-ceremony-text");
144
+ if (!header && !text) return;
145
+ const ui = resolveCeremonyUi(fields);
146
+ if (header) header.textContent = ui.header;
147
+ if (text) text.textContent = ui.explanation;
148
+ }
149
+
134
150
  /**
135
151
  * Show Confirm/Cancel. On Confirm, runs `runOnConfirm` **synchronously from the
136
152
  * click handler** so WebAuthn keeps user activation / document focus on mobile.
@@ -302,3 +318,68 @@ export function promptPassphrase(passwordText, buttonText, minPasswordLength) {
302
318
  root.append(input, button);
303
319
  });
304
320
  }
321
+
322
+ /**
323
+ * Prompt for a secp256k1 private key hex (with or without `0x`). Resolves with
324
+ * the trimmed hex string the user entered; rejects on Cancel.
325
+ *
326
+ * @returns {Promise<string>}
327
+ */
328
+ export function promptPrivateKey() {
329
+ if (!root) {
330
+ return Promise.reject(new Error("uiNotInitialized"));
331
+ }
332
+ cancelPendingCeremonyConfirm();
333
+ clearUi();
334
+ return new Promise((resolve, reject) => {
335
+ const header = document.createElement("h2");
336
+ header.className = "ows-ceremony-header";
337
+ header.textContent = "Import private key";
338
+ const body = document.createElement("p");
339
+ body.className = "ows-ceremony-text";
340
+ body.textContent =
341
+ "Paste your secp256k1 private key. It stays in this secure panel and is never sent to the host.";
342
+ const input = document.createElement("input");
343
+ input.type = "password";
344
+ input.className = "ows-password";
345
+ input.placeholder = "0x…";
346
+ input.autocomplete = "off";
347
+ input.spellcheck = false;
348
+ const errorEl = document.createElement("p");
349
+ errorEl.className = "ows-ceremony-text";
350
+ errorEl.style.color = "tomato";
351
+ errorEl.hidden = true;
352
+ const confirmBtn = document.createElement("button");
353
+ confirmBtn.type = "button";
354
+ confirmBtn.className = "ows-confirm";
355
+ confirmBtn.textContent = "Import";
356
+ const denyBtn = document.createElement("button");
357
+ denyBtn.type = "button";
358
+ denyBtn.className = "ows-deny";
359
+ denyBtn.textContent = "Cancel";
360
+ const actions = document.createElement("div");
361
+ actions.className = "ows-actions";
362
+ actions.append(confirmBtn, denyBtn);
363
+
364
+ const finish = (fn) => {
365
+ clearUi();
366
+ fn();
367
+ };
368
+
369
+ confirmBtn.addEventListener("click", () => {
370
+ const value = input.value.trim();
371
+ if (!value) {
372
+ errorEl.textContent = "Enter a private key.";
373
+ errorEl.hidden = false;
374
+ return;
375
+ }
376
+ finish(() => resolve(value));
377
+ });
378
+ denyBtn.addEventListener("click", () => {
379
+ finish(() => reject(new CeremonyDeniedError()));
380
+ });
381
+
382
+ root.append(header, body, input, errorEl, actions);
383
+ input.focus();
384
+ });
385
+ }