@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 +13 -0
- package/lib/entry.d.ts +11 -6
- package/lib/entry.js +38 -17
- package/lib/identity.d.ts +9 -3
- package/lib/identity.js +85 -51
- package/lib/index.d.ts +3 -4
- package/lib/index.js +1 -2
- package/lib/store.d.ts +26 -5
- package/lib/store.js +63 -7
- package/lib/utils.d.ts +56 -2
- package/lib/utils.js +66 -9
- package/package.json +10 -7
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 {
|
|
1
|
+
import type { MutableKeyEntry } from '@kokuin/token';
|
|
2
|
+
import { type StoredKeyRecord } from './utils.js';
|
|
2
3
|
export type GetStore = (mode?: IDBTransactionMode) => IDBObjectStore;
|
|
3
|
-
export
|
|
4
|
+
export type BrowserKeyEntryParams = {
|
|
5
|
+
keyID: string;
|
|
6
|
+
getStore: GetStore;
|
|
7
|
+
};
|
|
8
|
+
export declare class BrowserKeyEntry implements MutableKeyEntry<StoredKeyRecord> {
|
|
4
9
|
#private;
|
|
5
|
-
constructor(
|
|
10
|
+
constructor(params: BrowserKeyEntryParams);
|
|
6
11
|
get keyID(): string;
|
|
7
|
-
getAsync(): Promise<
|
|
8
|
-
setAsync(
|
|
9
|
-
provideAsync(): Promise<
|
|
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 {
|
|
1
|
+
import { generateKeyRecord } from './utils.js';
|
|
2
2
|
export class BrowserKeyEntry {
|
|
3
3
|
#getStore;
|
|
4
4
|
#keyID;
|
|
5
|
-
constructor(
|
|
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(
|
|
19
|
+
setAsync(record) {
|
|
20
20
|
return new Promise((resolve, reject)=>{
|
|
21
|
-
const
|
|
21
|
+
const store = this.#getStore('readwrite');
|
|
22
|
+
const request = store.put(record, this.#keyID);
|
|
22
23
|
request.onerror = ()=>reject(request.error);
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
54
|
+
const store = this.#getStore('readwrite');
|
|
55
|
+
const request = store.delete(this.#keyID);
|
|
38
56
|
request.onerror = ()=>reject(request.error);
|
|
39
|
-
|
|
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 {
|
|
3
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
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
|
-
//
|
|
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
|
|
27
|
-
const publicKey = await
|
|
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
|
|
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
|
-
},
|
|
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(
|
|
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 {
|
|
14
|
-
export {
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
6
|
-
constructor(
|
|
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(
|
|
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(
|
|
39
|
-
this.#getStore =
|
|
49
|
+
constructor(getStore){
|
|
50
|
+
this.#getStore = getStore;
|
|
40
51
|
}
|
|
41
52
|
entry(keyID) {
|
|
42
|
-
this.#entries[keyID] ??= new BrowserKeyEntry(
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
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.
|
|
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
|
|
30
|
-
"@sozai/codec": "^0.
|
|
31
|
-
"@sozai/log": "^0.
|
|
32
|
-
"@sozai/otel": "^0.
|
|
33
|
-
"@kokuin/
|
|
34
|
-
"@kokuin/
|
|
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",
|