@kokuin/browser 0.1.2 → 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 CHANGED
@@ -5,3 +5,16 @@
5
5
  ```sh
6
6
  npm install @kokuin/browser
7
7
  ```
8
+
9
+ ## Requirements
10
+
11
+ `BrowserKeyStore` holds a non-extractable Ed25519 signing key plus the X25519 agreement key
12
+ derived from it, and needs `SubtleCrypto` support for both algorithms: Chrome 137+, Firefox
13
+ 130+, or Safari 17+. On an older browser it hard-errors rather than falling back to ES256 — a
14
+ fallback would mint a different DID for the same keyID.
15
+
16
+ Key records minted before this requirement (ES256) keep working, but only for signing —
17
+ WebCrypto will not let an ECDSA key do `deriveBits`, so a legacy record cannot decrypt. Use
18
+ `store.provideSigningIdentity(keyID)` for one; `store.provideIdentity(keyID)` throws on it,
19
+ since it promises decryption. Legacy records are never silently re-keyed, since that would
20
+ change the identity's DID.
package/lib/entry.d.ts CHANGED
@@ -1,11 +1,16 @@
1
- import type { KeyEntry } from '@kokuin/token';
1
+ import type { MutableKeyEntry } from '@kokuin/token';
2
+ import { type StoredKeyRecord } from './utils.js';
2
3
  export type GetStore = (mode?: IDBTransactionMode) => IDBObjectStore;
3
- export declare class BrowserKeyEntry implements KeyEntry<CryptoKeyPair> {
4
+ export type BrowserKeyEntryParams = {
5
+ keyID: string;
6
+ getStore: GetStore;
7
+ };
8
+ export declare class BrowserKeyEntry implements MutableKeyEntry<StoredKeyRecord> {
4
9
  #private;
5
- constructor(keyID: string, getStore: GetStore);
10
+ constructor(params: BrowserKeyEntryParams);
6
11
  get keyID(): string;
7
- getAsync(): Promise<CryptoKeyPair | null>;
8
- setAsync(keyPair: CryptoKeyPair): Promise<void>;
9
- provideAsync(): Promise<CryptoKeyPair>;
12
+ getAsync(): Promise<StoredKeyRecord | null>;
13
+ setAsync(record: StoredKeyRecord): Promise<void>;
14
+ provideAsync(): Promise<StoredKeyRecord>;
10
15
  removeAsync(): Promise<void>;
11
16
  }
package/lib/entry.js CHANGED
@@ -1,10 +1,10 @@
1
- import { randomKeyPair } from './utils.js';
1
+ import { generateKeyRecord } from './utils.js';
2
2
  export class BrowserKeyEntry {
3
3
  #getStore;
4
4
  #keyID;
5
- constructor(keyID, getStore){
6
- this.#getStore = getStore;
7
- this.#keyID = keyID;
5
+ constructor(params){
6
+ this.#getStore = params.getStore;
7
+ this.#keyID = params.keyID;
8
8
  }
9
9
  get keyID() {
10
10
  return this.#keyID;
@@ -16,27 +16,48 @@ export class BrowserKeyEntry {
16
16
  request.onsuccess = ()=>resolve(request.result ?? null);
17
17
  });
18
18
  }
19
- setAsync(keyPair) {
19
+ setAsync(record) {
20
20
  return new Promise((resolve, reject)=>{
21
- const request = this.#getStore('readwrite').put(keyPair, this.#keyID);
21
+ const store = this.#getStore('readwrite');
22
+ const request = store.put(record, this.#keyID);
22
23
  request.onerror = ()=>reject(request.error);
23
- request.onsuccess = ()=>resolve();
24
+ const tx = store.transaction;
25
+ tx.oncomplete = ()=>resolve();
26
+ tx.onabort = ()=>reject(tx.error ?? new Error('Transaction aborted'));
27
+ tx.onerror = ()=>reject(tx.error ?? new Error('Transaction failed'));
24
28
  });
25
29
  }
26
- async provideAsync() {
27
- const existing = await this.getAsync();
28
- if (existing != null) {
29
- return existing;
30
- }
31
- const keyPair = await randomKeyPair();
32
- await this.setAsync(keyPair);
33
- return keyPair;
30
+ provideAsync() {
31
+ return generateKeyRecord().then((generated)=>new Promise((resolve, reject)=>{
32
+ const store = this.#getStore('readwrite');
33
+ const getRequest = store.get(this.#keyID);
34
+ let result = generated;
35
+ getRequest.onerror = ()=>reject(getRequest.error);
36
+ getRequest.onsuccess = ()=>{
37
+ const existing = getRequest.result;
38
+ if (existing != null) {
39
+ // A stored record ALWAYS wins over the freshly generated one — including a
40
+ // legacy ES256 record. Overwriting it would change the identity's DID.
41
+ result = existing;
42
+ } else {
43
+ store.put(generated, this.#keyID);
44
+ }
45
+ };
46
+ const tx = store.transaction;
47
+ tx.oncomplete = ()=>resolve(result);
48
+ tx.onabort = ()=>reject(tx.error ?? new Error('Transaction aborted'));
49
+ tx.onerror = ()=>reject(tx.error ?? new Error('Transaction failed'));
50
+ }));
34
51
  }
35
52
  removeAsync() {
36
53
  return new Promise((resolve, reject)=>{
37
- const request = this.#getStore('readwrite').delete(this.#keyID);
54
+ const store = this.#getStore('readwrite');
55
+ const request = store.delete(this.#keyID);
38
56
  request.onerror = ()=>reject(request.error);
39
- request.onsuccess = ()=>resolve();
57
+ const tx = store.transaction;
58
+ tx.oncomplete = ()=>resolve();
59
+ tx.onabort = ()=>reject(tx.error ?? new Error('Transaction aborted'));
60
+ tx.onerror = ()=>reject(tx.error ?? new Error('Transaction failed'));
40
61
  });
41
62
  }
42
63
  }
package/lib/identity.d.ts CHANGED
@@ -1,3 +1,9 @@
1
- import { type SigningIdentity } from '@kokuin/token';
2
- import { BrowserKeyStore } from './store.js';
3
- export declare function provideSigningIdentity(keyID: string, useStore?: BrowserKeyStore | Promise<BrowserKeyStore> | string): Promise<SigningIdentity>;
1
+ import { type FullIdentity, type SigningIdentity } from '@kokuin/token';
2
+ import { type BrowserKeyRecord, type LegacyES256Record } from './utils.js';
3
+ /**
4
+ * The current identity: non-extractable Ed25519 signing key, plus the X25519 agreement key
5
+ * derived from it. `did:key` EdDSA, so it is addressable by every other kokuin backend and
6
+ * decryptable by anyone who knows only its DID.
7
+ */
8
+ export declare function createBrowserIdentity(record: BrowserKeyRecord): Promise<FullIdentity>;
9
+ export declare function createLegacyES256Identity(record: LegacyES256Record): Promise<SigningIdentity>;
package/lib/identity.js CHANGED
@@ -1,30 +1,93 @@
1
- import { createTracer, KokuinAttributeKeys, KokuinSpanNames } from '@kokuin/otel';
2
- import { CODECS, getDID } from '@kokuin/token';
1
+ import { CODECS, decryptToken, getDID } from '@kokuin/token';
2
+ import { x25519 } from '@noble/curves/ed25519.js';
3
3
  import { p256 } from '@noble/curves/nist.js';
4
4
  import { b64uFromJSON, fromUTF, toB64U } from '@sozai/codec';
5
- import { getLogger } from '@sozai/log';
6
- import { withSpan } from '@sozai/otel';
7
- import { BrowserKeyStore } from './store.js';
8
- import { getPublicKey } from './utils.js';
9
- const tracer = createTracer('keystore.browser');
10
- const logger = getLogger([
11
- 'kokuin',
12
- 'browser'
13
- ]);
14
- // P-256 curve order, used to reflect a high-S signature to its low-S form.
5
+ import { getES256PublicKey } from './utils.js';
6
+ /**
7
+ * The current identity: non-extractable Ed25519 signing key, plus the X25519 agreement key
8
+ * derived from it. `did:key` EdDSA, so it is addressable by every other kokuin backend and
9
+ * decryptable by anyone who knows only its DID.
10
+ */ export async function createBrowserIdentity(record) {
11
+ const publicKey = record.publicKey;
12
+ const id = getDID(CODECS.EdDSA, publicKey);
13
+ // The agreement key is stored as raw bytes (WebKit cannot persist an X25519 CryptoKey; see
14
+ // BrowserKeyRecord). Re-import it here, non-extractable, for ECDH.
15
+ const agreementPublic = x25519.getPublicKey(record.agreementSecret);
16
+ const agreement = await globalThis.crypto.subtle.importKey('jwk', {
17
+ kty: 'OKP',
18
+ crv: 'X25519',
19
+ d: toB64U(record.agreementSecret),
20
+ x: toB64U(agreementPublic)
21
+ }, {
22
+ name: 'X25519'
23
+ }, false, [
24
+ 'deriveBits'
25
+ ]);
26
+ async function signToken(payload, options = {}) {
27
+ if (payload.iss != null && payload.iss !== id) {
28
+ throw new Error('Invalid payload: issuer does not match signer');
29
+ }
30
+ const fullHeader = {
31
+ ...options.header ?? {},
32
+ typ: 'JWT',
33
+ alg: 'EdDSA'
34
+ };
35
+ const fullPayload = {
36
+ ...payload,
37
+ iss: id
38
+ };
39
+ const data = `${b64uFromJSON(fullHeader)}.${b64uFromJSON(fullPayload)}`;
40
+ // Ed25519 signatures are canonical — no low-S normalization needed, unlike ECDSA.
41
+ const signature = await globalThis.crypto.subtle.sign({
42
+ name: 'Ed25519'
43
+ }, record.signing, fromUTF(data));
44
+ return {
45
+ header: fullHeader,
46
+ payload: fullPayload,
47
+ signature: toB64U(new Uint8Array(signature)),
48
+ data
49
+ };
50
+ }
51
+ async function agreeKey(ephemeralPublicKey) {
52
+ const ephemeral = await globalThis.crypto.subtle.importKey('raw', ephemeralPublicKey, {
53
+ name: 'X25519'
54
+ }, true, []);
55
+ const shared = await globalThis.crypto.subtle.deriveBits({
56
+ name: 'X25519',
57
+ public: ephemeral
58
+ }, agreement, 256);
59
+ return new Uint8Array(shared);
60
+ }
61
+ async function decrypt(jwe) {
62
+ return decryptToken({
63
+ id,
64
+ decrypt,
65
+ agreeKey
66
+ }, jwe);
67
+ }
68
+ return {
69
+ id,
70
+ publicKey,
71
+ signToken,
72
+ decrypt,
73
+ agreeKey
74
+ };
75
+ }
76
+ // --- Legacy ES256 path ---
77
+ //
78
+ // Records minted before the Ed25519 migration. They can only sign: WebCrypto will not let an
79
+ // ECDSA key do deriveBits, so they cannot do ECDH, so they cannot decrypt. They keep working,
80
+ // and they are NEVER silently re-keyed — that would change the identity's DID under it.
15
81
  const P256_N = p256.Point.Fn.ORDER;
16
- // Web Crypto's subtle.sign does not enforce low-S, so ~half of ECDSA signatures
17
- // have s > n/2 (high-S). The @kokuin/token verifier runs with `lowS: true` and
18
- // rejects those for malleability safety, so we normalize here for cross-stack
19
- // compatibility. `hasHighS()` is the same predicate noble's verifier applies.
82
+ // Web Crypto's subtle.sign does not enforce low-S, so ~half of ECDSA signatures have s > n/2.
83
+ // The @kokuin/token verifier runs with `lowS: true` and rejects those for malleability safety.
20
84
  function normalizeSignatureToLowS(sig) {
21
- // sig is IEEE P1363 compact format: r (32 bytes) || s (32 bytes).
22
85
  const parsed = p256.Signature.fromBytes(sig, 'compact');
23
86
  if (!parsed.hasHighS()) return sig;
24
87
  return new p256.Signature(parsed.r, P256_N - parsed.s).toBytes('compact');
25
88
  }
26
- async function createBrowserSigningIdentity(keyPair) {
27
- const publicKey = await getPublicKey(keyPair);
89
+ export async function createLegacyES256Identity(record) {
90
+ const publicKey = await getES256PublicKey(record);
28
91
  const id = getDID(CODECS.ES256, publicKey);
29
92
  async function signToken(payload, options = {}) {
30
93
  if (payload.iss != null && payload.iss !== id) {
@@ -40,17 +103,14 @@ async function createBrowserSigningIdentity(keyPair) {
40
103
  iss: id
41
104
  };
42
105
  const data = `${b64uFromJSON(fullHeader)}.${b64uFromJSON(fullPayload)}`;
43
- const messageBytes = fromUTF(data);
44
- const signatureBuffer = await globalThis.crypto.subtle.sign({
106
+ const signature = await globalThis.crypto.subtle.sign({
45
107
  name: 'ECDSA',
46
108
  hash: 'SHA-256'
47
- }, keyPair.privateKey, messageBytes.buffer);
48
- // Normalize to low-S: @kokuin/token verifier rejects high-S signatures
49
- const normalizedSig = normalizeSignatureToLowS(new Uint8Array(signatureBuffer));
109
+ }, record.privateKey, fromUTF(data));
50
110
  return {
51
111
  header: fullHeader,
52
112
  payload: fullPayload,
53
- signature: toB64U(normalizedSig),
113
+ signature: toB64U(normalizeSignatureToLowS(new Uint8Array(signature))),
54
114
  data
55
115
  };
56
116
  }
@@ -60,29 +120,3 @@ async function createBrowserSigningIdentity(keyPair) {
60
120
  signToken
61
121
  };
62
122
  }
63
- export async function provideSigningIdentity(keyID, useStore) {
64
- return withSpan(tracer, KokuinSpanNames.KEYSTORE_GET_OR_CREATE, {
65
- attributes: {
66
- [KokuinAttributeKeys.KEYSTORE_STORE_TYPE]: 'browser'
67
- }
68
- }, async (span)=>{
69
- const storePromise = useStore == null || typeof useStore === 'string' ? BrowserKeyStore.open(useStore) : Promise.resolve(useStore);
70
- const store = await storePromise;
71
- const entry = store.entry(keyID);
72
- const existing = await entry.getAsync();
73
- if (existing != null) {
74
- const identity = await createBrowserSigningIdentity(existing);
75
- span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
76
- span.setAttribute(KokuinAttributeKeys.KEYSTORE_KEY_CREATED, false);
77
- return identity;
78
- }
79
- const keyPair = await entry.provideAsync();
80
- const identity = await createBrowserSigningIdentity(keyPair);
81
- span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
82
- span.setAttribute(KokuinAttributeKeys.KEYSTORE_KEY_CREATED, true);
83
- logger.info('New identity generated: {did}', {
84
- did: identity.id
85
- });
86
- return identity;
87
- });
88
- }
package/lib/index.d.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  *
10
10
  * @module browser-keystore
11
11
  */
12
- export { BrowserKeyEntry } from './entry.js';
13
- export { provideSigningIdentity } from './identity.js';
14
- export { BrowserKeyStore } from './store.js';
15
- export { getPublicKey, randomKeyPair } from './utils.js';
12
+ export { BrowserKeyEntry, type BrowserKeyEntryParams, type GetStore } from './entry.js';
13
+ export { BrowserKeyStore, type BrowserKeyStoreParams } from './store.js';
14
+ export { assertEd25519Available, type BrowserKeyRecord, generateKeyRecord, isLegacyES256Record, type LegacyES256Record, type StoredKeyRecord, } from './utils.js';
package/lib/index.js CHANGED
@@ -9,6 +9,5 @@
9
9
  *
10
10
  * @module browser-keystore
11
11
  */ export { BrowserKeyEntry } from './entry.js';
12
- export { provideSigningIdentity } from './identity.js';
13
12
  export { BrowserKeyStore } from './store.js';
14
- export { getPublicKey, randomKeyPair } from './utils.js';
13
+ export { assertEd25519Available, generateKeyRecord, isLegacyES256Record } from './utils.js';
package/lib/store.d.ts CHANGED
@@ -1,8 +1,29 @@
1
- import type { KeyStore } from '@kokuin/token';
2
- import { BrowserKeyEntry } from './entry.js';
3
- export declare class BrowserKeyStore implements KeyStore<CryptoKeyPair, BrowserKeyEntry> {
1
+ import type { FullIdentity, IdentityProvider, KeyStore, SigningIdentity } from '@kokuin/token';
2
+ import { BrowserKeyEntry, type GetStore } from './entry.js';
3
+ import { type StoredKeyRecord } from './utils.js';
4
+ export type BrowserKeyStoreParams = {
5
+ /** The IndexedDB database name. Defaults to `'kokuin:key-store'`. */
6
+ name?: string;
7
+ };
8
+ export declare class BrowserKeyStore implements KeyStore<StoredKeyRecord, BrowserKeyEntry>, IdentityProvider<FullIdentity> {
4
9
  #private;
5
- static open(name?: string): Promise<BrowserKeyStore>;
6
- constructor(db: IDBDatabase);
10
+ static open(params?: BrowserKeyStoreParams): Promise<BrowserKeyStore>;
11
+ constructor(getStore: GetStore);
7
12
  entry(keyID: string): BrowserKeyEntry;
13
+ /**
14
+ * The full identity for `keyID` — signing and decryption.
15
+ *
16
+ * **Throws on a legacy ES256 record.** Such a record physically cannot decrypt (WebCrypto
17
+ * will not let an ECDSA key do `deriveBits`), and it is never silently re-keyed, because
18
+ * that would change the identity's DID. Use {@link provideSigningIdentity} to work with one.
19
+ */
20
+ provideIdentity(keyID: string): Promise<FullIdentity>;
21
+ /**
22
+ * A signing identity for `keyID`, accepting **both** suites.
23
+ *
24
+ * Returns a `FullIdentity` (a subtype of `SigningIdentity`) for a current record, and an
25
+ * ES256 signing identity for a legacy one. New code should prefer {@link provideIdentity},
26
+ * which promises decryption statically.
27
+ */
28
+ provideSigningIdentity(keyID: string): Promise<SigningIdentity>;
8
29
  }
package/lib/store.js CHANGED
@@ -1,15 +1,26 @@
1
+ import { createTracer, KokuinAttributeKeys, KokuinSpanNames } from '@kokuin/otel';
1
2
  import { defer } from '@sozai/async';
3
+ import { getLogger } from '@sozai/log';
4
+ import { withSpan } from '@sozai/otel';
2
5
  import { BrowserKeyEntry } from './entry.js';
6
+ import { createBrowserIdentity, createLegacyES256Identity } from './identity.js';
7
+ import { isLegacyES256Record } from './utils.js';
3
8
  const DEFAULT_DB_NAME = 'kokuin:key-store';
4
9
  const STORE_NAME = 'keys';
10
+ const tracer = createTracer('keystore.browser');
11
+ const logger = getLogger([
12
+ 'kokuin',
13
+ 'browser'
14
+ ]);
5
15
  function createGetStore(db) {
6
16
  return function getStore(mode = 'readonly') {
7
17
  return db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
8
18
  };
9
19
  }
10
20
  export class BrowserKeyStore {
11
- static #byName = {};
12
- static open(name = DEFAULT_DB_NAME) {
21
+ static #byName = Object.create(null);
22
+ static open(params) {
23
+ const name = params?.name ?? DEFAULT_DB_NAME;
13
24
  const existing = BrowserKeyStore.#byName[name];
14
25
  if (existing != null) {
15
26
  return existing;
@@ -26,20 +37,65 @@ export class BrowserKeyStore {
26
37
  }
27
38
  const request = indexedDB.open(name, 1);
28
39
  request.onerror = ()=>reject(request.error);
29
- request.onsuccess = ()=>resolve(new BrowserKeyStore(request.result));
40
+ request.onsuccess = ()=>resolve(new BrowserKeyStore(createGetStore(request.result)));
30
41
  request.onupgradeneeded = (event)=>{
31
42
  ;
32
43
  event.target.result.createObjectStore(STORE_NAME);
33
44
  };
34
45
  return promise;
35
46
  }
36
- #entries = {};
47
+ #entries = Object.create(null);
37
48
  #getStore;
38
- constructor(db){
39
- this.#getStore = createGetStore(db);
49
+ constructor(getStore){
50
+ this.#getStore = getStore;
40
51
  }
41
52
  entry(keyID) {
42
- this.#entries[keyID] ??= new BrowserKeyEntry(keyID, this.#getStore);
53
+ this.#entries[keyID] ??= new BrowserKeyEntry({
54
+ keyID,
55
+ getStore: this.#getStore
56
+ });
43
57
  return this.#entries[keyID];
44
58
  }
59
+ /**
60
+ * The full identity for `keyID` — signing and decryption.
61
+ *
62
+ * **Throws on a legacy ES256 record.** Such a record physically cannot decrypt (WebCrypto
63
+ * will not let an ECDSA key do `deriveBits`), and it is never silently re-keyed, because
64
+ * that would change the identity's DID. Use {@link provideSigningIdentity} to work with one.
65
+ */ async provideIdentity(keyID) {
66
+ return withSpan(tracer, KokuinSpanNames.KEYSTORE_GET_OR_CREATE, {
67
+ attributes: {
68
+ [KokuinAttributeKeys.KEYSTORE_STORE_TYPE]: 'browser'
69
+ }
70
+ }, async (span)=>{
71
+ const record = await this.entry(keyID).provideAsync();
72
+ if (isLegacyES256Record(record)) {
73
+ throw new Error(`Key "${keyID}" holds a legacy ES256 record, which cannot decrypt. It is not ` + "re-keyed automatically: that would change this identity's DID. Use " + 'provideSigningIdentity() to sign with it, or removeAsync() it first to mint a ' + 'new Ed25519 identity under a new DID.');
74
+ }
75
+ const identity = await createBrowserIdentity(record);
76
+ span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
77
+ logger.info('Browser identity resolved: {did}', {
78
+ did: identity.id
79
+ });
80
+ return identity;
81
+ });
82
+ }
83
+ /**
84
+ * A signing identity for `keyID`, accepting **both** suites.
85
+ *
86
+ * Returns a `FullIdentity` (a subtype of `SigningIdentity`) for a current record, and an
87
+ * ES256 signing identity for a legacy one. New code should prefer {@link provideIdentity},
88
+ * which promises decryption statically.
89
+ */ async provideSigningIdentity(keyID) {
90
+ return withSpan(tracer, KokuinSpanNames.KEYSTORE_GET_OR_CREATE, {
91
+ attributes: {
92
+ [KokuinAttributeKeys.KEYSTORE_STORE_TYPE]: 'browser'
93
+ }
94
+ }, async (span)=>{
95
+ const record = await this.entry(keyID).provideAsync();
96
+ const identity = isLegacyES256Record(record) ? await createLegacyES256Identity(record) : await createBrowserIdentity(record);
97
+ span.setAttribute(KokuinAttributeKeys.AUTH_DID, identity.id);
98
+ return identity;
99
+ });
100
+ }
45
101
  }
package/lib/utils.d.ts CHANGED
@@ -1,2 +1,56 @@
1
- export declare function getPublicKey(keyPair: CryptoKeyPair): Promise<Uint8Array>;
2
- export declare function randomKeyPair(): Promise<CryptoKeyPair>;
1
+ /**
2
+ * A key record minted by this version: a non-extractable Ed25519 signing key, the raw X25519
3
+ * agreement *secret* derived from the same seed, and the Ed25519 public key the DID is built
4
+ * from.
5
+ *
6
+ * The agreement key is stored as raw bytes, not as a non-extractable `CryptoKey`, because
7
+ * WebKit cannot persist an X25519 `CryptoKey` in IndexedDB — `put()` reports success but the
8
+ * subsequent `get()` returns `null` (https://bugs.webkit.org/show_bug.cgi?id=312279). A
9
+ * non-extractable Ed25519 `CryptoKey` persists fine, so only the agreement key degrades to
10
+ * bytes. See the backlog item tracking that bug; revert `agreementSecret` to a non-extractable
11
+ * `CryptoKey` once it is fixed.
12
+ *
13
+ * Security cost of the workaround: `agreementSecret` is the Ed25519 private scalar
14
+ * (`toMontgomerySecret`), so an attacker who can read IndexedDB (e.g. via XSS) can both decrypt
15
+ * to this identity and forge its signatures. The signing key itself stays non-extractable, but
16
+ * the scalar in `agreementSecret` is sufficient on its own — treat a stored record as secret.
17
+ *
18
+ * `suite` is what distinguishes it from a legacy record. An **untagged** record is ES256 by
19
+ * definition — that is all a pre-migration record can be.
20
+ */
21
+ export type BrowserKeyRecord = {
22
+ suite: 'Ed25519';
23
+ signing: CryptoKey;
24
+ agreementSecret: Uint8Array;
25
+ publicKey: Uint8Array;
26
+ };
27
+ /** A pre-migration record: a bare, untagged P-256 `CryptoKeyPair`. Signing only. */
28
+ export type LegacyES256Record = CryptoKeyPair;
29
+ export type StoredKeyRecord = BrowserKeyRecord | LegacyES256Record;
30
+ export declare function isLegacyES256Record(record: StoredKeyRecord): record is LegacyES256Record;
31
+ /**
32
+ * Throw unless WebCrypto can do Ed25519.
33
+ *
34
+ * There is deliberately **no fallback**. Falling back to P-256 would mint a different DID for
35
+ * the same keyID, which is identity loss dressed up as graceful degradation. Requires
36
+ * Chrome 137+, Firefox 130+, or Safari 17+.
37
+ */
38
+ export declare function assertEd25519Available(): Promise<void>;
39
+ /**
40
+ * Mint a new key record.
41
+ *
42
+ * The seed is generated **here**, not by `subtle.generateKey`, and the signing key is imported
43
+ * non-extractable. This is forced, not stylistic: `jwe.ts` derives a recipient's agreement key
44
+ * from its Ed25519 signing key (`toMontgomery`), so the agreement key MUST be the birational
45
+ * image of the signing key. A `generateKey`'d X25519 keypair is independent, and therefore
46
+ * unreachable by any sender — nothing addressed to the DID could ever be decrypted. Deriving
47
+ * the montgomery secret needs the Ed25519 private scalar, which a non-extractable `generateKey`
48
+ * result never yields.
49
+ *
50
+ * The seed is zeroed on the way out. The X25519 `agreementSecret` is **not** — it is the stored
51
+ * agreement key (WebKit cannot persist an X25519 `CryptoKey`; see {@link BrowserKeyRecord}). It
52
+ * equals the Ed25519 private scalar, so the returned record is sensitive.
53
+ */
54
+ export declare function generateKeyRecord(): Promise<BrowserKeyRecord>;
55
+ /** The compressed P-256 public key of a legacy record. Legacy path only. */
56
+ export declare function getES256PublicKey(keyPair: CryptoKeyPair): Promise<Uint8Array>;
package/lib/utils.js CHANGED
@@ -1,18 +1,75 @@
1
+ import { ed25519 } from '@noble/curves/ed25519.js';
2
+ import { toB64U } from '@sozai/codec';
3
+ export function isLegacyES256Record(record) {
4
+ return record.suite !== 'Ed25519';
5
+ }
6
+ /**
7
+ * Throw unless WebCrypto can do Ed25519.
8
+ *
9
+ * There is deliberately **no fallback**. Falling back to P-256 would mint a different DID for
10
+ * the same keyID, which is identity loss dressed up as graceful degradation. Requires
11
+ * Chrome 137+, Firefox 130+, or Safari 17+.
12
+ */ export async function assertEd25519Available() {
13
+ try {
14
+ await globalThis.crypto.subtle.generateKey({
15
+ name: 'Ed25519'
16
+ }, false, [
17
+ 'sign',
18
+ 'verify'
19
+ ]);
20
+ } catch (cause) {
21
+ throw new Error('WebCrypto does not support Ed25519, which @kokuin/browser requires (Chrome 137+, ' + 'Firefox 130+, Safari 17+). Refusing to fall back to another curve: it would mint a ' + 'different DID for the same keyID.', {
22
+ cause
23
+ });
24
+ }
25
+ }
26
+ /**
27
+ * Mint a new key record.
28
+ *
29
+ * The seed is generated **here**, not by `subtle.generateKey`, and the signing key is imported
30
+ * non-extractable. This is forced, not stylistic: `jwe.ts` derives a recipient's agreement key
31
+ * from its Ed25519 signing key (`toMontgomery`), so the agreement key MUST be the birational
32
+ * image of the signing key. A `generateKey`'d X25519 keypair is independent, and therefore
33
+ * unreachable by any sender — nothing addressed to the DID could ever be decrypted. Deriving
34
+ * the montgomery secret needs the Ed25519 private scalar, which a non-extractable `generateKey`
35
+ * result never yields.
36
+ *
37
+ * The seed is zeroed on the way out. The X25519 `agreementSecret` is **not** — it is the stored
38
+ * agreement key (WebKit cannot persist an X25519 `CryptoKey`; see {@link BrowserKeyRecord}). It
39
+ * equals the Ed25519 private scalar, so the returned record is sensitive.
40
+ */ export async function generateKeyRecord() {
41
+ await assertEd25519Available();
42
+ const seed = ed25519.utils.randomSecretKey();
43
+ const publicKey = ed25519.getPublicKey(seed);
44
+ const agreementSecret = ed25519.utils.toMontgomerySecret(seed);
45
+ try {
46
+ const signing = await globalThis.crypto.subtle.importKey('jwk', {
47
+ kty: 'OKP',
48
+ crv: 'Ed25519',
49
+ d: toB64U(seed),
50
+ x: toB64U(publicKey)
51
+ }, {
52
+ name: 'Ed25519'
53
+ }, false, [
54
+ 'sign'
55
+ ]);
56
+ return {
57
+ suite: 'Ed25519',
58
+ signing,
59
+ agreementSecret,
60
+ publicKey
61
+ };
62
+ } finally{
63
+ seed.fill(0);
64
+ }
65
+ }
1
66
  function ecPointCompress(x, y) {
2
67
  const out = new Uint8Array(x.length + 1);
3
68
  out[0] = 2 + (y[y.length - 1] & 1);
4
69
  out.set(x, 1);
5
70
  return out;
6
71
  }
7
- export async function getPublicKey(keyPair) {
72
+ /** The compressed P-256 public key of a legacy record. Legacy path only. */ export async function getES256PublicKey(keyPair) {
8
73
  const rawKey = await globalThis.crypto.subtle.exportKey('raw', keyPair.publicKey);
9
74
  return ecPointCompress(new Uint8Array(rawKey.slice(1, 33)), new Uint8Array(rawKey.slice(33, 65)));
10
75
  }
11
- export async function randomKeyPair() {
12
- return await globalThis.crypto.subtle.generateKey({
13
- name: 'ECDSA',
14
- namedCurve: 'P-256'
15
- }, false, [
16
- 'sign'
17
- ]);
18
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kokuin/browser",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "description": "Key store for browser",
6
6
  "keywords": [
@@ -26,12 +26,15 @@
26
26
  "sideEffects": false,
27
27
  "dependencies": {
28
28
  "@noble/curves": "^2.0.1",
29
- "@sozai/async": "^0.1.0",
30
- "@sozai/codec": "^0.1.0",
31
- "@sozai/log": "^0.1.0",
32
- "@sozai/otel": "^0.2.0",
33
- "@kokuin/token": "^0.1.1",
34
- "@kokuin/otel": "^0.1.0"
29
+ "@sozai/async": "^0.2.1",
30
+ "@sozai/codec": "^0.2.0",
31
+ "@sozai/log": "^0.2.0",
32
+ "@sozai/otel": "^0.3.0",
33
+ "@kokuin/otel": "^0.3.0",
34
+ "@kokuin/token": "^0.3.0"
35
+ },
36
+ "devDependencies": {
37
+ "@kokuin/keystore-conformance": "^0.0.0"
35
38
  },
36
39
  "scripts": {
37
40
  "build:clean": "del lib",