@1shotapi/ows-signer 0.4.1 → 0.4.2

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
@@ -68,8 +68,8 @@ Ceremony UI params (optional on WebAuthn-triggering methods): `explanationHeader
68
68
 
69
69
  ## PRF labels
70
70
 
71
- - `ows-v1/secp256k1`
72
- - `ows-v1/ed25519` (derived via HKDF from PRF output)
71
+ - `ows-v1/secp256k1` (HKDF from PRF output, rejection sampling into the curve order)
72
+ - `ows-v1/ed25519` (HKDF from the secp256k1 scalar — same material as `signDigest` / recovery)
73
73
  - `ows-v1/aes256-gcm` (HKDF from the secp256k1 scalar — same material as `signDigest`)
74
74
 
75
75
  ## Recovery envelope
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1shotapi/ows-signer",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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
@@ -4,6 +4,7 @@ export const API_VERSION = 1;
4
4
  export const SIGNER_VERSION = "0.1.0";
5
5
 
6
6
  export const PRF_LABEL_SECP256K1 = new TextEncoder().encode("ows-v1/secp256k1");
7
+ /** HKDF info for Ed25519 — IKM is the secp256k1 scalar (`signDigest` material). */
7
8
  export const PRF_LABEL_ED25519 = new TextEncoder().encode("ows-v1/ed25519");
8
9
  /** HKDF info for AES-256-GCM — IKM is the secp256k1 scalar (`signDigest` material). */
9
10
  export const PRF_LABEL_AES256 = new TextEncoder().encode("ows-v1/aes256-gcm");
package/src/crypto/prf.js CHANGED
@@ -76,16 +76,27 @@ export async function deriveScalarFromPrf(prfBuffer, infoLabel) {
76
76
  }
77
77
 
78
78
  /**
79
- * @param {ArrayBuffer} prfBuffer
79
+ * HKDF info `ows-v1/ed25519` — IKM is the secp256k1 scalar (`signDigest`
80
+ * material), not raw PRF. Recovery / import only hold that scalar, so Ed25519
81
+ * must chain through it or a recovery session would emit a different pubkey.
82
+ *
83
+ * @param {Uint8Array} secp256k1Scalar
80
84
  * @returns {Promise<Uint8Array>}
81
85
  */
82
- export async function deriveEd25519SeedFromPrf(prfBuffer) {
83
- return hkdfExpand(prfBuffer, PRF_LABEL_ED25519, 32);
86
+ export async function deriveEd25519SeedFromSecp256k1Scalar(secp256k1Scalar) {
87
+ return hkdfExpand(
88
+ secp256k1Scalar.buffer.slice(
89
+ secp256k1Scalar.byteOffset,
90
+ secp256k1Scalar.byteOffset + secp256k1Scalar.byteLength,
91
+ ),
92
+ PRF_LABEL_ED25519,
93
+ 32,
94
+ );
84
95
  }
85
96
 
86
97
  /**
87
98
  * @param {PublicKeyCredential} credential
88
- * @returns {Promise<{ secp256k1PrivateKey: Uint8Array, secp256k1PublicKey: Uint8Array, ed25519PublicKey: Uint8Array }>}
99
+ * @returns {Promise<{ secp256k1PrivateKey: Uint8Array, secp256k1PublicKey: Uint8Array, ed25519Seed: Uint8Array, ed25519PublicKey: Uint8Array }>}
89
100
  */
90
101
  export async function deriveKeysFromCredential(credential) {
91
102
  const rawPrf = /** @type {{ prf?: { results?: { first?: unknown } } }} */ (
@@ -100,12 +111,18 @@ export async function deriveKeysFromCredential(credential) {
100
111
  prfBuffer,
101
112
  PRF_LABEL_SECP256K1,
102
113
  );
103
- const ed25519Seed = await deriveEd25519SeedFromPrf(prfBuffer);
114
+ const ed25519Seed =
115
+ await deriveEd25519SeedFromSecp256k1Scalar(secp256k1PrivateKey);
104
116
  // Uncompressed (0x04 ‖ X ‖ Y) — required by viem `publicKeyToAddress`.
105
117
  const secp256k1PublicKey = secpGetPublicKey(secp256k1PrivateKey, false);
106
118
  const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
107
119
 
108
- return { secp256k1PrivateKey, secp256k1PublicKey, ed25519PublicKey };
120
+ return {
121
+ secp256k1PrivateKey,
122
+ secp256k1PublicKey,
123
+ ed25519Seed,
124
+ ed25519PublicKey,
125
+ };
109
126
  }
110
127
 
111
128
  /**
package/src/debug.js CHANGED
@@ -1,13 +1,74 @@
1
1
  const DEBUG_KEY = "ows-signer:debug";
2
2
 
3
+ /**
4
+ * True when a chrome-extension:// (or similar) page is an ancestor.
5
+ * Chromium exposes this via `location.ancestorOrigins` even when top is
6
+ * cross-origin — useful for side-panel embeds where WebAuthn often misbehaves.
7
+ * @returns {boolean}
8
+ */
9
+ export function isExtensionAncestor() {
10
+ try {
11
+ const origins = globalThis.location?.ancestorOrigins;
12
+ if (!origins || origins.length === 0) return false;
13
+ for (let i = 0; i < origins.length; i++) {
14
+ const origin = origins[i];
15
+ if (
16
+ typeof origin === "string" &&
17
+ (origin.startsWith("chrome-extension://") ||
18
+ origin.startsWith("moz-extension://") ||
19
+ origin.startsWith("safari-web-extension://"))
20
+ ) {
21
+ return true;
22
+ }
23
+ }
24
+ } catch {
25
+ // ignore
26
+ }
27
+ return false;
28
+ }
29
+
3
30
  /** @returns {boolean} */
4
31
  export function isDebug() {
5
32
  if (typeof globalThis === "undefined") return false;
6
- return (
7
- globalThis.OWS_SIGNER_DEBUG === true ||
8
- (typeof localStorage !== "undefined" &&
9
- localStorage.getItem(DEBUG_KEY) === "1")
10
- );
33
+ if (globalThis.OWS_SIGNER_DEBUG === true) return true;
34
+ // Always log in extension side-panel embeds — GPM get() often hangs with no UI.
35
+ if (isExtensionAncestor()) return true;
36
+ try {
37
+ return (
38
+ typeof localStorage !== "undefined" &&
39
+ localStorage.getItem(DEBUG_KEY) === "1"
40
+ );
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** @returns {Record<string, unknown>} */
47
+ export function describeCeremonyEnvironment() {
48
+ let visibilityState = null;
49
+ let hasFocus = null;
50
+ try {
51
+ visibilityState = document.visibilityState;
52
+ hasFocus = document.hasFocus?.() ?? null;
53
+ } catch {
54
+ // ignore
55
+ }
56
+ let ancestorOrigins = null;
57
+ try {
58
+ ancestorOrigins = globalThis.location?.ancestorOrigins
59
+ ? Array.from(globalThis.location.ancestorOrigins)
60
+ : null;
61
+ } catch {
62
+ ancestorOrigins = null;
63
+ }
64
+ return {
65
+ extensionAncestor: isExtensionAncestor(),
66
+ ancestorOrigins,
67
+ visibilityState,
68
+ hasFocus,
69
+ userActivationActive: navigator.userActivation?.isActive ?? null,
70
+ userActivationHasBeenActive: navigator.userActivation?.hasBeenActive ?? null,
71
+ };
11
72
  }
12
73
 
13
74
  /** @param {...unknown} args */
package/src/handlers.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  API_VERSION,
3
3
  METHODS,
4
- PRF_LABEL_ED25519,
5
4
  SIGN_SCHEMES,
6
5
  SIGNER_VERSION,
7
6
  } from "./constants.js";
@@ -10,9 +9,8 @@ import {
10
9
  encryptAes256Batch,
11
10
  } from "./crypto/aes256.js";
12
11
  import {
13
- deriveEd25519SeedFromPrf,
12
+ deriveEd25519SeedFromSecp256k1Scalar,
14
13
  deriveKeysFromCredential,
15
- hkdfExpand,
16
14
  normalizePrfOutputToArrayBuffer,
17
15
  } from "./crypto/prf.js";
18
16
  import { getPublicKeyAsync as edGetPublicKeyAsync } from "./crypto/vendor/noble-ed25519.js";
@@ -47,34 +45,6 @@ import {
47
45
  getCosePublicKeyBase64Url,
48
46
  } from "./webauthn.js";
49
47
 
50
- /**
51
- * @param {PublicKeyCredential} credential
52
- * @returns {Promise<Uint8Array>}
53
- */
54
- async function ed25519SeedFromCredential(credential) {
55
- const prfRaw = /** @type {{ prf?: { results?: { first?: unknown } } }} */ (
56
- credential.getClientExtensionResults()
57
- ).prf?.results?.first;
58
- const prfBuf = normalizePrfOutputToArrayBuffer(prfRaw);
59
- if (!prfBuf) throw new Error("noPrf");
60
- return deriveEd25519SeedFromPrf(prfBuf);
61
- }
62
-
63
- /**
64
- * @param {Uint8Array} secp256k1Scalar
65
- * @returns {Promise<Uint8Array>}
66
- */
67
- async function ed25519SeedFromSecp256k1Scalar(secp256k1Scalar) {
68
- return hkdfExpand(
69
- secp256k1Scalar.buffer.slice(
70
- secp256k1Scalar.byteOffset,
71
- secp256k1Scalar.byteOffset + secp256k1Scalar.byteLength,
72
- ),
73
- PRF_LABEL_ED25519,
74
- 32,
75
- );
76
- }
77
-
78
48
  /**
79
49
  * @param {string} targetOrigin
80
50
  * @param {string | undefined} correlationId
@@ -311,6 +281,7 @@ async function handleCreateCredential(params, correlationId, targetOrigin) {
311
281
  secp256k1PublicKey: to0xHex(keys.secp256k1PublicKey),
312
282
  });
313
283
  zeroize(keys.secp256k1PrivateKey);
284
+ zeroize(keys.ed25519Seed);
314
285
  });
315
286
  }
316
287
 
@@ -432,7 +403,7 @@ async function handleSignDigest(params, correlationId, targetOrigin) {
432
403
  if (hasRecoverySession()) {
433
404
  const cached = getRecoveryPrivateKey();
434
405
  if (!cached) throw new Error("recoverySessionEmpty");
435
- const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(cached);
406
+ const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(cached);
436
407
  try {
437
408
  const results = await signParsedDigests(
438
409
  items,
@@ -455,7 +426,6 @@ async function handleSignDigest(params, correlationId, targetOrigin) {
455
426
  () => getPasskeyAssertion(undefined, credentialId),
456
427
  );
457
428
  const keys = await deriveKeysFromCredential(credential);
458
- const ed25519Seed = await ed25519SeedFromCredential(credential);
459
429
  emitKeyDerived(
460
430
  targetOrigin,
461
431
  correlationId,
@@ -466,7 +436,7 @@ async function handleSignDigest(params, correlationId, targetOrigin) {
466
436
  const results = await signParsedDigests(
467
437
  items,
468
438
  keys.secp256k1PrivateKey,
469
- ed25519Seed,
439
+ keys.ed25519Seed,
470
440
  credentialId ?? getCredentialId(credential),
471
441
  );
472
442
  emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, {
@@ -474,7 +444,7 @@ async function handleSignDigest(params, correlationId, targetOrigin) {
474
444
  });
475
445
  } finally {
476
446
  zeroize(keys.secp256k1PrivateKey);
477
- zeroize(ed25519Seed);
447
+ zeroize(keys.ed25519Seed);
478
448
  }
479
449
  });
480
450
  }
@@ -506,6 +476,7 @@ async function handleRevealPrivateKey(params, correlationId, targetOrigin) {
506
476
  await dismiss;
507
477
  } finally {
508
478
  zeroize(keys.secp256k1PrivateKey);
479
+ zeroize(keys.ed25519Seed);
509
480
  }
510
481
  });
511
482
  emitEvent(
@@ -543,7 +514,7 @@ async function handleImportPrivateKey(_params, correlationId, targetOrigin) {
543
514
  setRecoveryPrivateKey(privateKey);
544
515
 
545
516
  const secp256k1PublicKey = secpGetPublicKey(privateKey, false);
546
- const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(privateKey);
517
+ const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(privateKey);
547
518
  const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
548
519
  emitKeyDerived(
549
520
  targetOrigin,
@@ -607,7 +578,7 @@ async function handleCreateRecoveryData(params, correlationId, targetOrigin) {
607
578
  const cached = getRecoveryPrivateKey();
608
579
  if (!cached) throw new Error("recoverySessionEmpty");
609
580
  const secp256k1PublicKey = secpGetPublicKey(cached, false);
610
- const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(cached);
581
+ const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(cached);
611
582
  const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
612
583
  emitKeyDerived(
613
584
  targetOrigin,
@@ -645,6 +616,7 @@ async function handleCreateRecoveryData(params, correlationId, targetOrigin) {
645
616
  passphrase,
646
617
  );
647
618
  zeroize(keys.secp256k1PrivateKey);
619
+ zeroize(keys.ed25519Seed);
648
620
  emitEvent(
649
621
  window.parent,
650
622
  targetOrigin,
@@ -683,7 +655,7 @@ async function handleRecoverKey(params, correlationId, targetOrigin) {
683
655
  // Emit public keys so OWSSigner can cache addresses (uncompressed secp256k1 —
684
656
  // viem publicKeyToAddress requires 0x04 ‖ X ‖ Y).
685
657
  const secp256k1PublicKey = secpGetPublicKey(privateKey, false);
686
- const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(privateKey);
658
+ const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(privateKey);
687
659
  const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
688
660
  emitKeyDerived(
689
661
  targetOrigin,
@@ -787,6 +759,7 @@ async function handleEncryptAES256(params, correlationId, targetOrigin) {
787
759
  });
788
760
  } finally {
789
761
  zeroize(keys.secp256k1PrivateKey);
762
+ zeroize(keys.ed25519Seed);
790
763
  }
791
764
  });
792
765
  }
@@ -847,6 +820,7 @@ async function handleDecryptAES256(params, correlationId, targetOrigin) {
847
820
  });
848
821
  } finally {
849
822
  zeroize(keys.secp256k1PrivateKey);
823
+ zeroize(keys.ed25519Seed);
850
824
  }
851
825
  });
852
826
  }
@@ -901,6 +875,7 @@ async function handleGetPublicKey(params, correlationId, targetOrigin) {
901
875
  }
902
876
 
903
877
  zeroize(keys.secp256k1PrivateKey);
878
+ zeroize(keys.ed25519Seed);
904
879
  });
905
880
  }
906
881
 
@@ -1031,7 +1006,6 @@ async function handleExecuteBatch(params, correlationId, targetOrigin) {
1031
1006
  () => getPasskeyAssertion(challenge, credentialId),
1032
1007
  );
1033
1008
  const keys = await deriveKeysFromCredential(credential);
1034
- const ed25519Seed = await ed25519SeedFromCredential(credential);
1035
1009
  emitKeyDerived(
1036
1010
  targetOrigin,
1037
1011
  correlationId,
@@ -1041,7 +1015,7 @@ async function handleExecuteBatch(params, correlationId, targetOrigin) {
1041
1015
  try {
1042
1016
  await runOps(
1043
1017
  keys.secp256k1PrivateKey,
1044
- ed25519Seed,
1018
+ keys.ed25519Seed,
1045
1019
  keys.secp256k1PublicKey,
1046
1020
  keys.ed25519PublicKey,
1047
1021
  credentialId ?? getCredentialId(credential),
@@ -1049,7 +1023,7 @@ async function handleExecuteBatch(params, correlationId, targetOrigin) {
1049
1023
  );
1050
1024
  } finally {
1051
1025
  zeroize(keys.secp256k1PrivateKey);
1052
- zeroize(ed25519Seed);
1026
+ zeroize(keys.ed25519Seed);
1053
1027
  }
1054
1028
  });
1055
1029
  return;
@@ -1057,7 +1031,7 @@ async function handleExecuteBatch(params, correlationId, targetOrigin) {
1057
1031
 
1058
1032
  const cached = getRecoveryPrivateKey();
1059
1033
  if (!cached) throw new Error("recoverySessionEmpty");
1060
- const ed25519Seed = await ed25519SeedFromSecp256k1Scalar(cached);
1034
+ const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(cached);
1061
1035
  const secp256k1PublicKey = secpGetPublicKey(cached, false);
1062
1036
  const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed);
1063
1037
  emitKeyDerived(
package/src/ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { to0xHex } from "./hex.js";
2
+ import { debugLog, describeCeremonyEnvironment } from "./debug.js";
2
3
 
3
4
  /** @type {HTMLElement | null} */
4
5
  let root = null;
@@ -223,6 +224,7 @@ export function promptCeremonyConfirm(fields, runOnConfirm) {
223
224
  try {
224
225
  // Start WebAuthn in the same turn as the click (no prior await).
225
226
  // Keep copy visible until the ceremony settles.
227
+ debugLog("ceremony Confirm click", describeCeremonyEnvironment());
226
228
  Promise.resolve(runOnConfirm()).then(settleSuccess, settleFailure);
227
229
  } catch (error) {
228
230
  settleFailure(error);
package/src/webauthn.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { getCeremonyAbortSignal, getRpId } from "./state.js";
2
2
  import { PRF_LABEL_SECP256K1 } from "./constants.js";
3
3
  import { bufferToBase64Url } from "./hex.js";
4
- import { debugLog, describePrfExtensionResults } from "./debug.js";
4
+ import {
5
+ debugLog,
6
+ describeCeremonyEnvironment,
7
+ describePrfExtensionResults,
8
+ } from "./debug.js";
5
9
 
6
10
  /**
7
11
  * @param {Uint8Array} [userId]
@@ -29,9 +33,7 @@ export async function createPasskeyCredential(name, options = {}) {
29
33
  const displayName = options.userDisplayName ?? name;
30
34
  const signal = getCeremonyAbortSignal();
31
35
 
32
- debugLog("createPasskeyCredential userActivation.isActive", {
33
- isActive: navigator.userActivation?.isActive ?? false,
34
- });
36
+ debugLog("createPasskeyCredential start", describeCeremonyEnvironment());
35
37
 
36
38
  const credential = await navigator.credentials.create({
37
39
  publicKey: {
@@ -88,27 +90,46 @@ export async function getPasskeyAssertion(challenge, credentialId) {
88
90
  : undefined;
89
91
  const signal = getCeremonyAbortSignal();
90
92
 
91
- const credential = await navigator.credentials.get({
92
- publicKey: {
93
- challenge: challenge ?? crypto.getRandomValues(new Uint8Array(32)),
94
- rpId,
95
- userVerification: "required",
96
- allowCredentials,
97
- extensions: {
98
- prf: {
99
- eval: {
100
- first: PRF_LABEL_SECP256K1,
93
+ debugLog("getPasskeyAssertion start", {
94
+ ...describeCeremonyEnvironment(),
95
+ rpId,
96
+ hasChallenge: Boolean(challenge),
97
+ hasAllowCredentials: Boolean(allowCredentials),
98
+ credentialIdPrefix:
99
+ typeof credentialId === "string" ? credentialId.slice(0, 8) : null,
100
+ });
101
+
102
+ let credential;
103
+ try {
104
+ credential = await navigator.credentials.get({
105
+ publicKey: {
106
+ challenge: challenge ?? crypto.getRandomValues(new Uint8Array(32)),
107
+ rpId,
108
+ userVerification: "required",
109
+ allowCredentials,
110
+ extensions: {
111
+ prf: {
112
+ eval: {
113
+ first: PRF_LABEL_SECP256K1,
114
+ },
101
115
  },
102
116
  },
103
117
  },
104
- },
105
- ...(signal ? { signal } : {}),
106
- });
118
+ ...(signal ? { signal } : {}),
119
+ });
120
+ } catch (error) {
121
+ debugLog("getPasskeyAssertion rejected", {
122
+ ...describeCeremonyEnvironment(),
123
+ name: error instanceof Error ? error.name : typeof error,
124
+ message: error instanceof Error ? error.message : String(error),
125
+ });
126
+ throw error;
127
+ }
107
128
 
108
129
  if (!credential || !(credential instanceof PublicKeyCredential)) {
109
130
  throw new Error("credentialGetFailed");
110
131
  }
111
- debugLog("getPasskeyAssertion", describePrfExtensionResults(credential));
132
+ debugLog("getPasskeyAssertion ok", describePrfExtensionResults(credential));
112
133
  return credential;
113
134
  }
114
135