@openlfcp/crypto 0.1.5 → 0.2.0-beta.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 +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local-state.d.ts +42 -0
- package/dist/local-state.js +154 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ ESM only; Node.js 24 or later (browsers and Electron for every package except `@
|
|
|
12
12
|
|
|
13
13
|
Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
|
|
14
14
|
subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
|
|
15
|
-
at `mvp-0.1-baseline.
|
|
15
|
+
at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
|
|
16
16
|
claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
|
|
17
|
-
(`0.1.
|
|
17
|
+
(`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
|
|
18
18
|
|
|
19
19
|
## Links
|
|
20
20
|
|
package/dist/index.d.ts
CHANGED
|
@@ -10,4 +10,5 @@ export { hkdfExpand, hkdfExtract } from "./hkdf.js";
|
|
|
10
10
|
export { openDek, type SealedDek, sealDek } from "./hpke.js";
|
|
11
11
|
export { InvitationSecret } from "./invitation.js";
|
|
12
12
|
export { AgreementKeyPair, exportSecretKeyBytes, generateAgreementKeyPair, generateSigningKeyPair, importAgreementKey, importSigningKey, isValidEd25519PublicKey, SigningKeyPair, verifyEd25519, } from "./keys.js";
|
|
13
|
+
export { exportLocalStateKey, isSealedLocal, LocalStateKey, localEnvelopeGeneration, localStateCipher, openLocal, sealLocal, } from "./local-state.js";
|
|
13
14
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -10,4 +10,5 @@ export { hkdfExpand, hkdfExtract } from "./hkdf.js";
|
|
|
10
10
|
export { openDek, sealDek } from "./hpke.js";
|
|
11
11
|
export { InvitationSecret } from "./invitation.js";
|
|
12
12
|
export { AgreementKeyPair, exportSecretKeyBytes, generateAgreementKeyPair, generateSigningKeyPair, importAgreementKey, importSigningKey, isValidEd25519PublicKey, SigningKeyPair, verifyEd25519, } from "./keys.js";
|
|
13
|
+
export { exportLocalStateKey, isSealedLocal, LocalStateKey, localEnvelopeGeneration, localStateCipher, openLocal, sealLocal, } from "./local-state.js";
|
|
13
14
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
declare const INSPECT: unique symbol;
|
|
2
|
+
/** The 32-byte local state key of one install and generation. */
|
|
3
|
+
export declare class LocalStateKey {
|
|
4
|
+
#private;
|
|
5
|
+
private constructor();
|
|
6
|
+
/** A fresh key from the platform CSPRNG. */
|
|
7
|
+
static generate(): LocalStateKey;
|
|
8
|
+
/** The key stored in a SecretStore. */
|
|
9
|
+
static import(bytes: Uint8Array): LocalStateKey;
|
|
10
|
+
toJSON(): string;
|
|
11
|
+
toString(): string;
|
|
12
|
+
[INSPECT](): string;
|
|
13
|
+
}
|
|
14
|
+
/** A copy of the key bytes, for the SecretStore only. */
|
|
15
|
+
export declare function exportLocalStateKey(key: LocalStateKey): Uint8Array;
|
|
16
|
+
/** Whether `bytes` is an envelope: the magic, and room for its header, nonce and tag. */
|
|
17
|
+
export declare function isSealedLocal(bytes: Uint8Array): boolean;
|
|
18
|
+
/** The generation an envelope was sealed with, or undefined when `bytes` is not one. */
|
|
19
|
+
export declare function localEnvelopeGeneration(bytes: Uint8Array): number | undefined;
|
|
20
|
+
/** The envelope of `plaintext` under `key` of `generation`, bound to `aad`. */
|
|
21
|
+
export declare function sealLocal(key: LocalStateKey, generation: number, aad: Uint8Array, plaintext: Uint8Array): Uint8Array;
|
|
22
|
+
/**
|
|
23
|
+
* The plaintext of `envelope`, sealed under `key` and bound to `aad`. A wrong
|
|
24
|
+
* key, AAD or generation, or tampered bytes, is AEAD_AUTHENTICATION_FAILED.
|
|
25
|
+
*/
|
|
26
|
+
export declare function openLocal(key: LocalStateKey, aad: Uint8Array, envelope: Uint8Array): Uint8Array;
|
|
27
|
+
/**
|
|
28
|
+
* The local state cipher for @openlfcp/storage's LocalStateKeyring
|
|
29
|
+
* (`LocalStateCipher`): storage adapters take it from the application, so
|
|
30
|
+
* the storage packages do no cryptography and the key bytes stay here.
|
|
31
|
+
*/
|
|
32
|
+
export declare const localStateCipher: Readonly<{
|
|
33
|
+
generateKey: () => LocalStateKey;
|
|
34
|
+
importKey: (bytes: Uint8Array) => LocalStateKey;
|
|
35
|
+
exportKey: (key: LocalStateKey) => Uint8Array;
|
|
36
|
+
seal: typeof sealLocal;
|
|
37
|
+
open: typeof openLocal;
|
|
38
|
+
isSealed: typeof isSealedLocal;
|
|
39
|
+
generationOf: typeof localEnvelopeGeneration;
|
|
40
|
+
}>;
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=local-state.d.ts.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
2
|
+
import { randomBytes } from "@noble/hashes/utils.js";
|
|
3
|
+
import { LfcpError } from "@openlfcp/core";
|
|
4
|
+
/**
|
|
5
|
+
* Local state encrypted at rest (LFCP-02-098; .github
|
|
6
|
+
* docs/devel/design/local-state-encryption.md §3, §4).
|
|
7
|
+
*
|
|
8
|
+
* A device keeps one local state key per install, in its SecretStore, and
|
|
9
|
+
* seals the records that hold profile text (checkpoints, section journals)
|
|
10
|
+
* before they reach the disk. An envelope is
|
|
11
|
+
*
|
|
12
|
+
* "lse1" || generation (uint32 BE) || nonce (24 bytes) || ciphertext || tag (16 bytes)
|
|
13
|
+
*
|
|
14
|
+
* under XChaCha20-Poly1305 with a random nonce per write, safe at any write
|
|
15
|
+
* count: local rows need no nonce counter. The AEAD's associated data is the
|
|
16
|
+
* envelope's first 8 bytes (magic and generation) followed by the caller's
|
|
17
|
+
* AAD, which binds the row to its install, store and key, so a row cannot be
|
|
18
|
+
* swapped with another one or moved to another install, and its generation
|
|
19
|
+
* cannot be altered.
|
|
20
|
+
*
|
|
21
|
+
* Like every key in this package, the key keeps its bytes in a private field
|
|
22
|
+
* and prints as "[redacted]"; the only way out is `exportLocalStateKey`, for
|
|
23
|
+
* the SecretStore. Errors never include key, plaintext or envelope bytes.
|
|
24
|
+
*/
|
|
25
|
+
const MAGIC = Uint8Array.from([0x6c, 0x73, 0x65, 0x31]); // "lse1"
|
|
26
|
+
const HEADER_LENGTH = 8;
|
|
27
|
+
const NONCE_LENGTH = 24;
|
|
28
|
+
const TAG_LENGTH = 16;
|
|
29
|
+
const KEY_LENGTH = 32;
|
|
30
|
+
const REDACTED = "[redacted]";
|
|
31
|
+
const INSPECT = Symbol.for("nodejs.util.inspect.custom");
|
|
32
|
+
let readKey;
|
|
33
|
+
/** The 32-byte local state key of one install and generation. */
|
|
34
|
+
export class LocalStateKey {
|
|
35
|
+
#bytes;
|
|
36
|
+
static {
|
|
37
|
+
readKey = (key) => key.#bytes;
|
|
38
|
+
}
|
|
39
|
+
constructor(bytes) {
|
|
40
|
+
if (!(bytes instanceof Uint8Array) || bytes.length !== KEY_LENGTH)
|
|
41
|
+
throw new LfcpError("INVALID_LENGTH", `a local state key is exactly ${KEY_LENGTH} bytes`);
|
|
42
|
+
this.#bytes = Uint8Array.from(bytes);
|
|
43
|
+
}
|
|
44
|
+
/** A fresh key from the platform CSPRNG. */
|
|
45
|
+
static generate() {
|
|
46
|
+
const bytes = randomBytes(KEY_LENGTH);
|
|
47
|
+
try {
|
|
48
|
+
return new LocalStateKey(bytes);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
bytes.fill(0);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The key stored in a SecretStore. */
|
|
55
|
+
static import(bytes) {
|
|
56
|
+
return new LocalStateKey(bytes);
|
|
57
|
+
}
|
|
58
|
+
toJSON() {
|
|
59
|
+
return REDACTED;
|
|
60
|
+
}
|
|
61
|
+
toString() {
|
|
62
|
+
return REDACTED;
|
|
63
|
+
}
|
|
64
|
+
[INSPECT]() {
|
|
65
|
+
return REDACTED;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** A copy of the key bytes, for the SecretStore only. */
|
|
69
|
+
export function exportLocalStateKey(key) {
|
|
70
|
+
if (!(key instanceof LocalStateKey))
|
|
71
|
+
throw new LfcpError("CRYPTO_FAILURE", "expected a LocalStateKey");
|
|
72
|
+
return Uint8Array.from(readKey(key));
|
|
73
|
+
}
|
|
74
|
+
/** Whether `bytes` is an envelope: the magic, and room for its header, nonce and tag. */
|
|
75
|
+
export function isSealedLocal(bytes) {
|
|
76
|
+
return (bytes instanceof Uint8Array &&
|
|
77
|
+
bytes.length >= HEADER_LENGTH + NONCE_LENGTH + TAG_LENGTH &&
|
|
78
|
+
MAGIC.every((b, i) => bytes[i] === b));
|
|
79
|
+
}
|
|
80
|
+
/** The generation an envelope was sealed with, or undefined when `bytes` is not one. */
|
|
81
|
+
export function localEnvelopeGeneration(bytes) {
|
|
82
|
+
if (!isSealedLocal(bytes))
|
|
83
|
+
return undefined;
|
|
84
|
+
return new DataView(bytes.buffer, bytes.byteOffset, HEADER_LENGTH).getUint32(4);
|
|
85
|
+
}
|
|
86
|
+
const header = (generation) => {
|
|
87
|
+
if (!Number.isInteger(generation) || generation < 1 || generation > 0xffffffff)
|
|
88
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "a local state generation is an integer in [1, 2^32)");
|
|
89
|
+
const out = new Uint8Array(HEADER_LENGTH);
|
|
90
|
+
out.set(MAGIC);
|
|
91
|
+
new DataView(out.buffer).setUint32(4, generation);
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
const concat = (a, b) => {
|
|
95
|
+
const out = new Uint8Array(a.length + b.length);
|
|
96
|
+
out.set(a);
|
|
97
|
+
out.set(b, a.length);
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
/** The envelope of `plaintext` under `key` of `generation`, bound to `aad`. */
|
|
101
|
+
export function sealLocal(key, generation, aad, plaintext) {
|
|
102
|
+
const head = header(generation);
|
|
103
|
+
const nonce = randomBytes(NONCE_LENGTH);
|
|
104
|
+
const k = Uint8Array.from(exportLocalStateKey(key));
|
|
105
|
+
try {
|
|
106
|
+
const sealed = xchacha20poly1305(k, nonce, concat(head, aad)).encrypt(plaintext);
|
|
107
|
+
const out = new Uint8Array(HEADER_LENGTH + NONCE_LENGTH + sealed.length);
|
|
108
|
+
out.set(head);
|
|
109
|
+
out.set(nonce, HEADER_LENGTH);
|
|
110
|
+
out.set(sealed, HEADER_LENGTH + NONCE_LENGTH);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new LfcpError("CRYPTO_FAILURE", "XChaCha20-Poly1305 encryption failed");
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
k.fill(0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The plaintext of `envelope`, sealed under `key` and bound to `aad`. A wrong
|
|
122
|
+
* key, AAD or generation, or tampered bytes, is AEAD_AUTHENTICATION_FAILED.
|
|
123
|
+
*/
|
|
124
|
+
export function openLocal(key, aad, envelope) {
|
|
125
|
+
if (!isSealedLocal(envelope))
|
|
126
|
+
throw new LfcpError("AEAD_AUTHENTICATION_FAILED", "not a local state envelope");
|
|
127
|
+
const head = envelope.subarray(0, HEADER_LENGTH);
|
|
128
|
+
const nonce = envelope.subarray(HEADER_LENGTH, HEADER_LENGTH + NONCE_LENGTH);
|
|
129
|
+
const k = Uint8Array.from(exportLocalStateKey(key));
|
|
130
|
+
try {
|
|
131
|
+
return xchacha20poly1305(k, nonce, concat(head, aad)).decrypt(envelope.subarray(HEADER_LENGTH + NONCE_LENGTH));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw new LfcpError("AEAD_AUTHENTICATION_FAILED", "the local state envelope does not authenticate under this key and AAD");
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
k.fill(0);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The local state cipher for @openlfcp/storage's LocalStateKeyring
|
|
142
|
+
* (`LocalStateCipher`): storage adapters take it from the application, so
|
|
143
|
+
* the storage packages do no cryptography and the key bytes stay here.
|
|
144
|
+
*/
|
|
145
|
+
export const localStateCipher = Object.freeze({
|
|
146
|
+
generateKey: () => LocalStateKey.generate(),
|
|
147
|
+
importKey: (bytes) => LocalStateKey.import(bytes),
|
|
148
|
+
exportKey: (key) => exportLocalStateKey(key),
|
|
149
|
+
seal: sealLocal,
|
|
150
|
+
open: openLocal,
|
|
151
|
+
isSealed: isSealedLocal,
|
|
152
|
+
generationOf: localEnvelopeGeneration,
|
|
153
|
+
});
|
|
154
|
+
//# sourceMappingURL=local-state.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/crypto",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-beta.2",
|
|
4
4
|
"description": "The cryptography of LFCP (SHA-256, Ed25519, X25519, HKDF, ChaCha20-Poly1305, HPKE) on audited primitives (@noble, hpke).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
],
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public",
|
|
44
|
-
"tag": "
|
|
44
|
+
"tag": "beta"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@noble/ciphers": "^2.4.0",
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"@noble/hashes": "^2.4.0",
|
|
50
50
|
"@panva/hpke-noble": "^1.1.7",
|
|
51
51
|
"hpke": "^1.1.7",
|
|
52
|
-
"@openlfcp/core": "^0.
|
|
52
|
+
"@openlfcp/core": "^0.2.0-beta.2"
|
|
53
53
|
}
|
|
54
54
|
}
|