@bakobo/fiki 0.0.1 → 0.6.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 +71 -2
- package/package.json +32 -4
- package/src/base.js +325 -0
- package/src/bytes.js +32 -0
- package/src/errors.js +91 -0
- package/src/index.js +42 -0
- package/src/keys.js +225 -0
- package/src/messages.js +791 -0
- package/src/sfv.js +219 -0
package/src/keys.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Ed25519 keys, whose public half *is* the identifier (`this.i` @07wstqk7, @2q9gv70t).
|
|
2
|
+
//
|
|
3
|
+
// Everything here goes through WebCrypto, which is why the port needs no crypto dependency and
|
|
4
|
+
// why every method is async — that asymmetry with the Python port is forced by the platform
|
|
5
|
+
// rather than chosen.
|
|
6
|
+
//
|
|
7
|
+
// Keys are NON-EXTRACTABLE by default. A browser key that JavaScript cannot read cannot be
|
|
8
|
+
// exfiltrated by an XSS bug, which is the dominant threat for a browser-held signing key; the
|
|
9
|
+
// cost is that the identity is bound to one browser profile and a new device registers a new AID.
|
|
10
|
+
// `generate({extractable: true})` is the opt-in for a caller who needs a portable identity, and
|
|
11
|
+
// `seed()` throws rather than returning nothing when the key cannot produce one.
|
|
12
|
+
|
|
13
|
+
import { fromBase64Url, toBase64Url } from './bytes.js';
|
|
14
|
+
import { MalformedKey } from './errors.js';
|
|
15
|
+
|
|
16
|
+
// CESR's Ed25519N (non-transferable Ed25519 verification key). fiki decodes this code and no
|
|
17
|
+
// other: a parser that handles one fixed-length code can only ever be narrower than a full CESR
|
|
18
|
+
// implementation, which is the safe direction for a differential.
|
|
19
|
+
const CODE = 'B';
|
|
20
|
+
const RAW_LEN = 32;
|
|
21
|
+
const QB64_LEN = 44;
|
|
22
|
+
|
|
23
|
+
// WebCrypto refuses a raw private key and accepts PKCS#8, and a PKCS#8 Ed25519 private key is a
|
|
24
|
+
// fixed DER prefix followed by the 32-byte seed — so this is a concatenation rather than an
|
|
25
|
+
// ASN.1 encoder.
|
|
26
|
+
const PKCS8_PREFIX = Uint8Array.from([
|
|
27
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const ALGORITHM = { name: 'Ed25519' };
|
|
31
|
+
|
|
32
|
+
/** Render a raw 32-byte Ed25519 public key as a non-transferable AID. */
|
|
33
|
+
export function toAid(raw) {
|
|
34
|
+
const padded = new Uint8Array(RAW_LEN + 1);
|
|
35
|
+
padded.set(raw, 1);
|
|
36
|
+
return CODE + toBase64Url(padded).slice(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// libsodium's has_small_order blocklist (tick 27eo, `this.i` @4wcwlqd6): the encodings of the
|
|
40
|
+
// points whose order divides 8, plus the non-canonical y = p and y = p + 1, compared with the sign
|
|
41
|
+
// bit of the last byte masked. Under such a key a fixed signature verifies over any message, so it
|
|
42
|
+
// binds nothing; test/small-order.test.js decodes every entry and checks its order.
|
|
43
|
+
const hex = (text) => Uint8Array.from(text.match(/../g), (pair) => parseInt(pair, 16));
|
|
44
|
+
export const SMALL_ORDER = Object.freeze([
|
|
45
|
+
hex('00'.repeat(32)), // order 4
|
|
46
|
+
hex('01' + '00'.repeat(31)), // the identity, order 1
|
|
47
|
+
hex('26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05'), // order 8
|
|
48
|
+
hex('c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a'), // order 8
|
|
49
|
+
hex('ec' + 'ff'.repeat(30) + '7f'), // p - 1, order 2
|
|
50
|
+
hex('ed' + 'ff'.repeat(30) + '7f'), // p, a non-canonical 0, order 4
|
|
51
|
+
hex('ee' + 'ff'.repeat(30) + '7f'), // p + 1, a non-canonical 1, the identity
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const smallOrder = (raw) =>
|
|
55
|
+
SMALL_ORDER.some((entry) => entry.every((byte, i) => (i === RAW_LEN - 1 ? raw[i] & 0x7f : raw[i]) === byte));
|
|
56
|
+
|
|
57
|
+
// RFC 8032 section 5.1.3's decoding, as far as deciding whether 32 bytes ARE a point: y below p,
|
|
58
|
+
// x squared = (y^2 - 1) / (d y^2 + 1) a square mod p, and no sign bit on an x of zero. BigInt rather
|
|
59
|
+
// than a dependency, because this is a yes-or-no question and needs no curve arithmetic beyond it.
|
|
60
|
+
const P = 2n ** 255n - 19n;
|
|
61
|
+
const mod = (a) => ((a % P) + P) % P;
|
|
62
|
+
function power(base, exponent) {
|
|
63
|
+
let result = 1n;
|
|
64
|
+
for (let b = mod(base), e = exponent; e > 0n; e >>= 1n, b = mod(b * b)) if (e & 1n) result = mod(result * b);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
const D = mod(-121665n * power(121666n, P - 2n));
|
|
68
|
+
|
|
69
|
+
function canonicalPoint(raw) {
|
|
70
|
+
let y = 0n;
|
|
71
|
+
for (let i = RAW_LEN - 1; i >= 0; i -= 1) y = (y << 8n) | BigInt(raw[i]);
|
|
72
|
+
const sign = y >> 255n;
|
|
73
|
+
y &= (1n << 255n) - 1n;
|
|
74
|
+
if (y >= P) return false;
|
|
75
|
+
const x2 = mod((y * y - 1n) * power(D * y * y + 1n, P - 2n));
|
|
76
|
+
// x of zero has one encoding only, with the sign bit clear.
|
|
77
|
+
if (x2 === 0n) return sign === 0n;
|
|
78
|
+
// Euler's criterion: x2 has a square root mod p exactly when x2^((p-1)/2) is 1.
|
|
79
|
+
return power(x2, (P - 1n) / 2n) === 1n;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Refuse a 32-byte public key that is not a canonical point, or is of small order (27eo). */
|
|
83
|
+
export function checkKey(raw, keyid) {
|
|
84
|
+
if (!canonicalPoint(raw)) {
|
|
85
|
+
throw new MalformedKey(
|
|
86
|
+
`The key for "${keyid}" is not the canonical encoding of a point on the Ed25519 curve, so ` +
|
|
87
|
+
'it is not a key fiki will verify with.',
|
|
88
|
+
{ keyid },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (smallOrder(raw)) {
|
|
92
|
+
throw new MalformedKey(
|
|
93
|
+
`The key for "${keyid}" is a small-order Ed25519 point, under which a signature can be ` +
|
|
94
|
+
'forged for any message, so it is not a key fiki will verify with.',
|
|
95
|
+
{ keyid },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Recover the raw 32-byte Ed25519 public key from a non-transferable AID. */
|
|
102
|
+
export function verifyingKey(aid) {
|
|
103
|
+
if (typeof aid !== 'string' || aid.length !== QB64_LEN || !aid.startsWith(CODE)) {
|
|
104
|
+
throw new MalformedKey(
|
|
105
|
+
`A non-transferable AID is ${QB64_LEN} characters beginning with "${CODE}"; this one is ` +
|
|
106
|
+
`${typeof aid === 'string' ? aid.length : 0} characters and begins with "${String(aid).slice(0, 1)}".`,
|
|
107
|
+
{ keyid: aid },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
// Strict rather than lenient: "=" is inside base64's alphabet, so a 44-character string of the
|
|
111
|
+
// right shape can still decode short, and a decoder is exactly the place a quiet shortfall
|
|
112
|
+
// turns into somebody else's exception.
|
|
113
|
+
if (!/^[A-Za-z0-9\-_]{44}$/.test(aid)) {
|
|
114
|
+
throw new MalformedKey(`The AID "${aid}" is not valid base64url.`, { keyid: aid });
|
|
115
|
+
}
|
|
116
|
+
// No length check after this, and the asymmetry with the Python port is deliberate: there,
|
|
117
|
+
// base64's alphabet includes "=", so a 44-character AID can be padded and still decode short.
|
|
118
|
+
// Here the character class excludes "=", so 44 valid characters always decode to 33 bytes and a
|
|
119
|
+
// length check would be unreachable code claiming to guard something.
|
|
120
|
+
const decoded = fromBase64Url('A' + aid.slice(1));
|
|
121
|
+
// The second character's top two bits land in the pad byte the code replaced, so a non-zero pad
|
|
122
|
+
// would give one key two spellings. Only the canonical one, the one toAid produces, is the AID
|
|
123
|
+
// (bakobo/fiki#4).
|
|
124
|
+
if (decoded[0] !== 0) {
|
|
125
|
+
throw new MalformedKey(`The AID "${aid}" is not the canonical spelling of its key.`, { keyid: aid });
|
|
126
|
+
}
|
|
127
|
+
return checkKey(decoded.slice(1), aid);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The one-character codes whose 44-character qb64 carries 32 raw bytes behind one pad byte:
|
|
131
|
+
// Ed25519N (B), Ed25519 transferable (D), and Blake3-256 (E, the usual AID digest).
|
|
132
|
+
const SPELLED_CODES = 'BDE';
|
|
133
|
+
|
|
134
|
+
/** True when `keyid` is shaped like a B, D or E AID and is not its canonical spelling.
|
|
135
|
+
*
|
|
136
|
+
* That is, 44 characters under one of those codes whose remaining 43 are not base64url, or which
|
|
137
|
+
* decode with a non-zero pad byte and so name the same 32 bytes as another spelling. fiki checks
|
|
138
|
+
* this before any resolver sees the keyid, so a resolver never has to (bakobo/fiki#4).
|
|
139
|
+
*/
|
|
140
|
+
export function misspelledAid(keyid) {
|
|
141
|
+
if (keyid.length !== QB64_LEN || !SPELLED_CODES.includes(keyid[0])) return false;
|
|
142
|
+
if (!/^[A-Za-z0-9\-_]{43}$/.test(keyid.slice(1))) return true;
|
|
143
|
+
return fromBase64Url('A' + keyid.slice(1))[0] !== 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** An Ed25519 key pair whose public half is rendered as a non-transferable AID. */
|
|
147
|
+
export class Key {
|
|
148
|
+
constructor(privateKey, publicRaw, seedBytes) {
|
|
149
|
+
this._privateKey = privateKey;
|
|
150
|
+
this._publicRaw = publicRaw;
|
|
151
|
+
this._seed = seedBytes;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Create a key from fresh randomness. Non-extractable unless asked otherwise. */
|
|
155
|
+
static async generate({ extractable = false } = {}) {
|
|
156
|
+
const pair = await crypto.subtle.generateKey(ALGORITHM, extractable, ['sign', 'verify']);
|
|
157
|
+
const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
|
|
158
|
+
let seedBytes = null;
|
|
159
|
+
if (extractable) {
|
|
160
|
+
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey));
|
|
161
|
+
seedBytes = pkcs8.slice(PKCS8_PREFIX.length);
|
|
162
|
+
}
|
|
163
|
+
return new Key(pair.privateKey, publicRaw, seedBytes);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Recreate a key from its 32-byte Ed25519 seed. */
|
|
167
|
+
static async fromSeed(seed, { extractable = false } = {}) {
|
|
168
|
+
if (!(seed instanceof Uint8Array) || seed.length !== RAW_LEN) {
|
|
169
|
+
throw new MalformedKey(
|
|
170
|
+
`An Ed25519 seed is ${RAW_LEN} bytes; this one is ${seed?.length ?? 0}.`,
|
|
171
|
+
{ keyid: '' },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const pkcs8 = new Uint8Array(PKCS8_PREFIX.length + RAW_LEN);
|
|
175
|
+
pkcs8.set(PKCS8_PREFIX);
|
|
176
|
+
pkcs8.set(seed, PKCS8_PREFIX.length);
|
|
177
|
+
// WebCrypto offers no way to derive a public key from a private one, and the AID *is* the
|
|
178
|
+
// public key, so it has to come out of the key material somehow. A JWK export carries it in
|
|
179
|
+
// "x". That needs an extractable handle, so the seed is imported twice: once extractable and
|
|
180
|
+
// only to read "x", and once with whatever extractability the caller asked for, which is the
|
|
181
|
+
// handle that actually signs. The throwaway is never returned and never stored.
|
|
182
|
+
const forExport = await crypto.subtle.importKey('pkcs8', pkcs8, ALGORITHM, true, ['sign']);
|
|
183
|
+
const { x } = await crypto.subtle.exportKey('jwk', forExport);
|
|
184
|
+
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, ALGORITHM, extractable, ['sign']);
|
|
185
|
+
return new Key(privateKey, fromBase64Url(x), extractable ? seed : null);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The non-transferable AID — 44 characters, `B` prefixed, also the verifying key. */
|
|
189
|
+
get aid() {
|
|
190
|
+
return toAid(this._publicRaw);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The raw verifying key, base64url and unpadded — the RFC 8037 JWK "x" form (@7xrx5evg). */
|
|
194
|
+
get keyid() {
|
|
195
|
+
return toBase64Url(this._publicRaw);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The 32-byte seed, for a caller that has to persist the key somewhere. */
|
|
199
|
+
get seed() {
|
|
200
|
+
if (this._seed === null) {
|
|
201
|
+
throw new MalformedKey(
|
|
202
|
+
'This key is non-extractable, so its seed cannot be read. Create it with ' +
|
|
203
|
+
'generate({extractable: true}) if the identity has to outlive this browser profile.',
|
|
204
|
+
{ keyid: this.aid },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return this._seed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Sign bytes, returning the raw 64-byte Ed25519 signature. */
|
|
211
|
+
async sign(data) {
|
|
212
|
+
return new Uint8Array(await crypto.subtle.sign(ALGORITHM, this._privateKey, data));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Verify a raw signature against an AID's recovered key. */
|
|
217
|
+
export async function verifySignature(aid, signature, data) {
|
|
218
|
+
return verifyWithRaw(verifyingKey(aid), signature, data);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Verify a raw signature against a raw 32-byte Ed25519 public key. */
|
|
222
|
+
export async function verifyWithRaw(raw, signature, data) {
|
|
223
|
+
const key = await crypto.subtle.importKey('raw', raw, ALGORITHM, false, ['verify']);
|
|
224
|
+
return crypto.subtle.verify(ALGORITHM, key, signature, data);
|
|
225
|
+
}
|