Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@ on:
pull_request:

jobs:
pkvault:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18.x, 22.x]
defaults:
run:
working-directory: apps/pkvault
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.0
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: node -e 'require("@pksuite/crypto")'
working-directory: apps/pkvault
- run: pnpm build
- run: git diff --exit-code -- packages/crypto/dist
- run: pnpm test
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
node_modules/
dist/
!packages/crypto/dist/
!packages/crypto/dist/**
packages/crypto/test/mobile/runner.bundle.js
packages/crypto/test/mobile/runner.bundle.js.LEGAL.txt
.wrangler/
.env
.env.*
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ hosted service to trust.
|---|---|---|
| [pkvault](apps/pkvault) | pre-release | Passkey-encrypted `.env` files for teams |

The independently specified [`@pksuite/crypto`](packages/crypto) package
provides the product-neutral streaming file envelope shared by future tools.

A product appears here only when it has a maintained implementation or a
committed near-term release.

Expand Down Expand Up @@ -45,6 +48,7 @@ live in [`spec/research`](spec/research).

- `apps/` — deployable products (imported with full history; pkvault's
pre-monorepo commits are preserved from `ddyy/pkvault`)
- `packages/` — reusable, product-neutral libraries
- `spec/` — specifications; `spec/research/` is exploratory, non-roadmap

## Releases
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ running `setup` for a new identity and being re-added to every vault.
## Development

```sh
npm test
pnpm --filter pkvault test
```

## License
Expand Down
3 changes: 3 additions & 0 deletions apps/pkvault/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@pksuite/crypto": "0.1.0"
}
}
28 changes: 13 additions & 15 deletions apps/pkvault/src/age.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"use strict";
// Minimal age v1 (X25519 recipients only) on node:crypto. Zero deps.
// Minimal age v1 (X25519 recipients only) on node:crypto. Identity string
// encoding and public-key derivation are shared with @pksuite/crypto; the
// legacy vault envelope remains implemented with node:crypto.
// Writer: never greases, X25519 stanzas only (SPEC §5 writer obligations).
// Reader: strict — any non-X25519 stanza rejects (SPEC §5 reader validation).

const crypto = require("node:crypto");
const bech32 = require("./bech32");
const shared = require("@pksuite/crypto");

const VERSION_LINE = "age-encryption.org/v1";
// Fixed DER prefixes for raw X25519 key import (RFC 8410 structures).
Expand All @@ -26,8 +28,7 @@ function pubKey(raw32) {
return crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw32]), format: "der", type: "spki" });
}
function publicFromScalar(scalar32) {
const pub = crypto.createPublicKey(privKey(scalar32));
return pub.export({ format: "der", type: "spki" }).subarray(-32);
return Buffer.from(shared.publicKeyFromSecretKey(scalar32));
}
function x25519(scalar32, theirPub32) {
return crypto.diffieHellman({ privateKey: privKey(scalar32), publicKey: pubKey(theirPub32) });
Expand All @@ -52,21 +53,18 @@ function chachaOpen(key, nonce, ctAndTag) {

// --- identity / recipient strings -------------------------------------------
function keygen(scalar32 = crypto.randomBytes(32)) {
return { scalar: scalar32, recipient: encodeRecipient(publicFromScalar(scalar32)), identity: encodeIdentity(scalar32) };
const pair = shared.createRecipientIdentityFromSecret(scalar32);
return { scalar: scalar32, recipient: pair.recipient, identity: pair.identity };
}
const encodeRecipient = (pub32) => bech32.encode("age", pub32);
const encodeIdentity = (scalar32) => bech32.encode("age-secret-key-", scalar32).toUpperCase();
const encodeRecipient = (pub32) => shared.formatRecipient(pub32);
const encodeIdentity = (scalar32) => shared.formatIdentity(scalar32);
function decodeRecipient(str) {
if (str !== str.toLowerCase()) return null; // canonical lowercase required (SPEC §2.2)
const d = bech32.decode(str);
if (!d || d.hrp !== "age" || d.data.length !== 32) return null;
if (bech32.encode("age", d.data) !== str) return null; // canonical re-encoding
return d.data;
try { return Buffer.from(shared.parseRecipient(str)); }
catch { return null; }
}
function decodeIdentity(str) {
const d = bech32.decode(str);
if (!d || d.hrp !== "age-secret-key-" || d.data.length !== 32) return null;
return d.data;
try { return Buffer.from(shared.parseIdentity(str)); }
catch { return null; }
}

// --- encrypt ------------------------------------------------------------------
Expand Down
66 changes: 0 additions & 66 deletions apps/pkvault/src/bech32.js

This file was deleted.

2 changes: 1 addition & 1 deletion apps/pkvault/src/format.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"use strict";
// pkvault v1 wire format (SPEC.md draft 0.10). Zero deps beyond node:crypto.
// pkvault v1 wire format (SPEC.md draft 0.10). This module uses node:crypto.
// Verification order (SPEC §6.2): structural parse → unseal FK → verify MAC → decrypt.

const crypto = require("node:crypto");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/agent.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";
// Agent suite: lazy one-ceremony unlock, caching, TTL, lock, socket hygiene,
// and the full sync-CLI-through-agent path. node:test, zero deps.
// and the full sync-CLI-through-agent path. Uses node:test.
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/bin.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";
// CLI arg-parsing safety (bin.js): fail-closed on unknown/misspelled/conflicting
// flags and invalid --ttl. Exercises the real binary via spawnSync. zero deps.
// flags and invalid --ttl. Exercises the real binary via spawnSync.
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/bridge.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use strict";
// Bridge suite: the ceremony transport protocol with a Node-simulated page
// (same ECDH/HKDF/AES-GCM the browser does in WebCrypto), plus setup/recover/
// ceremonyUnlock end-to-end with a simulated authenticator. node:test, zero deps.
// ceremonyUnlock end-to-end with a simulated authenticator. Uses node:test.
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/cli.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use strict";
// CLI integration suite: init → set/get/run → add (guard) → remove (rotation +
// checklist + accepted-exposure marker) → status, plus non-interactive refusals
// and interrupted-txn recovery on next mutation. node:test, zero deps.
// and interrupted-txn recovery on next mutation. Uses node:test.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/editor.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";
// Editor suite: F1 classification, F2 sentinels, F8 set-stdin, F10 sentinel
// rename, F11 public-refusal (SPEC §§9, 11.4). node:test, zero deps.
// rename and F11 public-refusal (SPEC §§9, 11.4). Uses node:test.
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/identity.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";
// Identity-blob suite (SPEC-IDENTITY v1): PRF/recovery wraps, self-authentication,
// recovery-burns-both semantics, parse/version errors. node:test, zero deps.
// recovery-burns-both semantics and parse/version errors. Uses node:test.
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/merge.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";
// Merge suite: F3 disjoint, F4 rotated, F5 conflicts, F6 override boundary,
// F12 case-fold (SPEC §§8, 11.4). node:test, zero deps.
// F12 case-fold (SPEC §§8, 11.4). Uses node:test.
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/wire.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use strict";
// Wire-fixture suite (SPEC 0.10 §§11.2–11.3). Runs every fixtures/manifest.json
// entry through the pipeline and asserts the exact expected error code or
// success properties. node:test, zero deps.
// success properties. Uses node:test.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/test/workflow.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";
// Workflow suite: F7 crash/adversary matrix (SPEC §8.2), F9 locking (§8.3),
// manifest + config parsing (SPEC-MANIFEST). node:test, zero deps.
// Manifest + config parsing (SPEC-MANIFEST). Uses node:test.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,14 @@
"engines": {
"node": ">=18"
},
"scripts": {
"build": "pnpm --filter @pksuite/crypto build",
"test": "pnpm --filter @pksuite/crypto test && pnpm --filter pkvault test"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"packageManager": "pnpm@10.33.0"
}
21 changes: 21 additions & 0 deletions packages/crypto/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Daniel Yang

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
74 changes: 74 additions & 0 deletions packages/crypto/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# `@pksuite/crypto`

Browser and Node cryptographic primitives shared by the pk suite. Version
0.1.0 provides canonical age/X25519 identities and the streaming `PKFILE1`
file envelope. It has no workflow, storage-provider, or network concepts.
The ESM browser entry is intended for an application bundler. Its declared
`@noble/*` dependencies remain external in both ESM and CommonJS artifacts so
consumer lockfile updates change the code that actually executes.

```js
import {
createRecipientIdentity,
createFileEncryption,
disposeEnvelope,
encryptChunk,
finalizeEnvelope,
openFileEnvelope,
decryptChunk,
} from "@pksuite/crypto";

const recipient = createRecipientIdentity();
const encryption = createFileEncryption({
recipient: recipient.recipient,
plaintextSize: file.size,
metadata: {
name: file.name,
mediaType: file.type || "application/octet-stream",
lastModified: file.lastModified,
},
});

for (let index = 0; index < encryption.chunkCount; index++) {
const start = index * encryption.chunkSize;
const plaintext = new Uint8Array(await file.slice(start, start + encryption.chunkSize).arrayBuffer());
const ciphertext = encryptChunk({ state: encryption, index, plaintext });
// Persist or upload this independently authenticated chunk before reading
// the next slice. Retrying an index produces a fresh XChaCha nonce.
await storeChunk(index, ciphertext);
}
finalizeEnvelope(encryption);

const decryption = openFileEnvelope({ identity: recipient.identity, header: encryption.header });
for (let index = 0; index < decryption.chunkCount; index++) {
const plaintext = decryptChunk({ state: decryption, index, ciphertext: await loadChunk(index) });
await consumePlaintext(plaintext);
}
finalizeEnvelope(decryption);
```

`finalizeEnvelope` best-effort overwrites the file key retained by its state
and makes that state unusable. Call `disposeEnvelope(state)` on cancellation or
any abandoned upload/decryption. JavaScript cannot guarantee erasure of engine
or cryptographic-library internal copies.

Metadata names are encrypted portable filename components, not paths. A
consumer that writes files must still join the name beneath an explicitly
chosen directory and must not decode or reinterpret it as path syntax.

The encrypted header exposes file size, chunk geometry, recipient count, and
header length. Filename, media type, and modification time are encrypted. See
[`dist/SPEC-FILE.md`](dist/SPEC-FILE.md) in the package, or `spec/SPEC-FILE.md`
in the repository, for the implementation-independent wire format and threat
contract.

`vectors/file-v1.json` contains fixed identity, header, metadata, nonce, AAD,
and chunk outputs. An independent Node reference reader opens that frozen
vector in the test suite. Every structured case in
`vectors/file-v1-tampering.json` is executed by the conformance tests. The
frozen `age-cli-v1.age` artifact was written by the external age CLI.

The mobile release gate is recorded under `test/mobile/reports/` for Android
Chrome and iOS Safari. Both devices completed foreground and background-resume
runs with matching byte counts. Safari does not expose `performance.memory`;
the harness records that measurement as unavailable.
Loading
Loading