Repository navigation
feat: add opt-in Falcon-1024 accounts - #40
Merged
Merged
Conversation
Adds a custom Vault secrets engine that manages Algorand post-quantum
(Falcon-1024) accounts, which the transit engine cannot do natively.
The plugin derives a Falcon-1024 keypair deterministically from 32 bytes of
entropy (the 25-word mnemonic) and stores the entropy, the canonical address
salt and the key material in the mount's barrier-encrypted storage — a PQ
account needs more than the mnemonic, since one public key can derive up to
256 addresses depending on the salt. Derivation follows go-algorand exactly
(SHA512-256 "PQK"/"PQA" domain separation, off-curve salt scan) and is pinned
by the official algokey test vector.
The API mirrors transit so the service can reuse its existing call patterns:
POST keys/{name} creates (idempotent), GET keys/{name} returns scheme, salt,
public key and address, POST sign/{name} signs base64 input, LIST keys
enumerates. Deletion, rotation, versioning and export are deliberately absent.
Wiring: scripts/build_vault_plugin.sh builds a static musl binary into the
plugin directory the vault container now mounts, and development-init
registers it by sha256, mounts it at pawn/pq-users and extends the AppRole
policies (users create, managers create/list/sign). CI builds the plugin
before the stack starts and runs the Go tests under the race detector.
The plugin is pinned to Go 1.23 because Vault 1.15 injects
GODEBUG=x509sha1=1 into the plugin processes it spawns and Go removed that
setting in 1.24; a newer toolchain aborts before the go-plugin handshake.
Go tests cover derivation against the official algokey vector, persistence
across a fresh backend instance and concurrent creates. The e2e suite adds a
block that talks to the mount directly — no service endpoint exposes PQ
accounts yet — re-deriving the returned address independently in TypeScript
and asserting the user AppRole can create but not sign.
Coverage report
Test suite run success239 tests passing in 24 suites. Report generated by 🧪jest coverage report action from 8e40a19 |
Wraps the `algorand-pq` secrets engine added in the previous commit so the service layer can reach Falcon-1024 keys the same way it already reaches transit ed25519 ones. Pure addition: no existing method, call site or response shape changes, and nothing yet calls these. The four wrappers mirror their transit siblings — create (idempotent), read, sign, list — and share one private request helper, since the plugin does not imitate transit's quirks and so has none of the per-method special casing the transit wrappers carry. Two differences from those siblings are deliberate. Signatures come back raw: the plugin emits no `vault:v1:` envelope, so nothing may run the transit prefix split over them, and `decodeVaultSignature` in particular would reject every Falcon signature on its 64-byte length assertion. And a missing key is an answer rather than an error: `pqGetKey` returns `undefined` on 404 because the account-type probe that follows reads a miss as "this user_id is not a PQ account". `pqSign` keeps throwing on 404, since an unknown key there is a real failure. The mount comes from a new `VAULT_PQ_USERS_PATH` in `.env.template`, falling back to `pawn/pq-users` the way the KV mount falls back to `secret`. `development-init` seeds `.env` only when the file is absent, so without the fallback every existing checkout would resolve an undefined mount against a Vault that has the engine mounted correctly. The path stays a literal in `development-init.ts` and the e2e suite: the former writes `.env` from the template and so cannot consume the variable it creates, which is why its transit paths are literals too. Unit tests cover response parsing, the mount override and namespace header, the 404-to-`undefined` path, that the signing input is passed through base64 unmodified, and that a raw signature survives with no prefix stripped. Verified against the running dev stack as well: a key created through these methods reads back identically, re-creating it is idempotent, signing yields a 1226-byte buffer, and the user AppRole is denied on sign while the manager succeeds.
Exposes the Falcon-1024 accounts the previous two commits made reachable. `POST /v1/wallet/user/` gains one optional `account_type` field; omitting it produces exactly the ed25519 account it produced before. No other endpoint takes a new parameter — every route keys off `user_id`, and the service works out the rest. Which mount holds a `user_id` is the only record of its account type, so nothing can drift out of step with Vault. `resolveUserAccount` probes transit first and falls through to the PQ mount on a 404, which means accounts that exist today resolve in exactly the request they take now and only PQ accounts pay for the miss. A non-404 from transit propagates instead of falling through: "cannot tell" is not the same answer as "not an ed25519 account". `getUserInfo` is reimplemented on top of the resolver with its signature and response shape unchanged, so its eight call sites are untouched. Creation refuses a `user_id` that already exists in the other mount, with a 409. Without that guard the same `user_id` would resolve to two different addresses depending on probe order — funds to whichever account the resolver happened to find first. Account type is therefore fixed at creation: the two schemes derive different addresses, so there is no conversion to offer. Listing now covers both mounts, and returns real addresses. The internal `UserInfoDto.public_address` had been carrying a base64 public key that the wallet layer converted on the way out, which is meaningless for a 1793-byte Falcon key; both branches now produce the address directly and the conversion is gone. The transit LIST also stops turning Vault's 404-on-empty-mount into a 500, which becomes reachable for the first time here — a deployment can hold PQ users and no transit users at all. `account_type` is additive on the wire but not invisible: it appears in every user response, so a caller asserting an exact body shape will see it. The existing `User detail` end-to-end case did, and was updated. Signing and fees are not part of this change. `signTxAsUser` and `addSignatureToTxn` are untouched, so no ed25519 transaction can regress; a PQ account can be created and read but not yet spent from, which needs the `pqsig` envelope and algod v5. Unit tests cover the create branch and both 409 directions, transit-first resolution, the fall-through, the 404 when neither mount holds the user, and that a non-404 error is not swallowed. The end-to-end suite drives the endpoints: a falcon1024 create returns the address the plugin derived, reading the user back resolves it with no hint from the caller, a default create is still ed25519, both types appear in the listing, and an unknown account_type is rejected.
Bring the plan in line with what increments 1 and 3 actually shipped, and settle the increment 4 design against the published SDKs. algokit-utils cannot emit a pqsig envelope in any released version - checked through 10.0.0-beta.4, the newest on npm - so increment 4 cannot be written against the packages currently installed. algosdk 3.7.0 has the full feature: SignedTransaction.pqsig, EncodedPQSig, addressFromPQKey/addressFromPQSig, an async remote-signer adapter shaped like a Vault call, and an empty-signature signer that makes algod report the real post-quantum fee under simulate. Adding algosdk 3.7.0 is therefore a hard prerequisite for increment 4, scoped to the PQ envelope and address helpers only; transaction building, grouping and encoding stay on algokit-utils. That supersedes the earlier plan to hand-assemble the envelope and promote algorand-msgpack to a direct dependency - hand-rolling canonical msgpack on a money path is the wrong trade when the SDK's own schema encoder is available. The two SDKs interoperate at the byte level, verified against the real packages: algosdk decodes algokit's encoded transaction (TX prefix stripped) and re-encodes it byte-identically, its bytesToSign output matches algokit's encodeTransaction exactly, an ed25519 signed transaction encodes identically under both, and the PQ address helpers agree with the Vault plugin's derivation. The rest tracks reality: increment 4 is no longer blocked on an unavailable algod version now that v5 is released, the increment 1 notes describe the plugin version pinning and mount verification that were built, account_type is required rather than optional, the signing call sites still discard the resolved UserAccount, and the review findings increment 4 has to clear are listed explicitly alongside the non-atomic cross-mount check left for cleanup.
bwmx
force-pushed
the
feat/pq-accounts
branch
from
September 8, 2026 09:51
01b3e53 to
25b4299
Compare
…s for PQ users Add e2e cases for a PQ-signed payment and app-call group, the public asset holdings route, asset clawback from a PQ user, and an app call with ABI arguments.
bwmx
force-pushed
the
feat/pq-accounts
branch
from
September 14, 2026 13:08
7319d5b to
62fcbdd
Compare
`docker build` without `--platform` produces a binary for the Docker daemon's default platform, which only coincidentally matched the Vault container on a dev machine: both followed the host architecture. On CI the two are chosen independently, and a plugin binary built for a different architecture than the Vault container dies before the go-plugin handshake, leaving only Vault's opaque "Failed to read any lines from plugin's stdout" behind a 400 on mount. Resolve the build platform from the Vault image itself so the binary and the container that execs it always agree.
Vault spawns plugins with VAULT_PLUGIN_MLOCK_ENABLED=true, and the SDK calls mlockall before the go-plugin handshake. The plugin binary has no cap_ipc_lock (setcap does not survive the bind mount) and runs as uid 100, so it is bounded by RLIMIT_MEMLOCK. Docker Desktop defaults that to unlimited; dockerd on the GitHub Ubuntu runner defaults to 8 MiB, which the ~15 MB static binary exceeds. The plugin exited with "cannot allocate memory" and Vault reported only the generic "not compiled for this architecture" error, even though the log showed EM_X86_64 on amd64. Set ulimits.memlock: -1 on the vault service. No-op on Docker Desktop, fixes the Initialize Vault step on CI.
bwmx
force-pushed
the
feat/pq-accounts
branch
from
September 14, 2026 13:11
62fcbdd to
4f16327
Compare
bwmx
commented
Sep 15, 2026
bwmx
marked this pull request as ready for review
September 16, 2026 15:21
bwmx
marked this pull request as draft
September 16, 2026 16:02
bwmx
marked this pull request as ready for review
September 17, 2026 11:52
PhearZero
self-requested a review
September 21, 2026 18:55
PhearZero
reviewed
Sep 21, 2026
PhearZero
left a comment
Collaborator
There was a problem hiding this comment.
I tested this out locally and it's working as expected. Let's create a new stack on top of this so we can review the manager operations e2e before merging.
PhearZero
added this pull request to stack #45
September 29, 2026 14:42
PhearZero
self-requested a review
September 29, 2026 14:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Opt-in Falcon-1024 user accounts
Adds opt-in Falcon-1024 Algorand user accounts backed by a custom Vault secrets engine while preserving the existing Ed25519 API behavior when
account_typeis omitted.Account type is fixed at creation. A KV CAS claim serializes concurrent creation across application instances, so one user ID cannot be created in both Vault mounts. Because PQ creation has only run in development, this change does not include migration, backfill, or an audit command.
What changed
account_type: "falcon1024"on user creation and returnaccount_typein user responsesValidation
yarn buildyarn lintyarn formatyarn test --runInBand— 24 suites, 239 testsGOCACHE=/private/tmp/intermezzo-go-cache go test -race ./...yarn test:e2e— 2 suites, 46 tests against algod 5 and the Vault pluginDeferred
Reviewer notes
This PR adds opt-in Falcon-1024 accounts while keeping Ed25519 as the default. Roughly 70% of added LOC is tests, CI/setup, or lockfiles.
Review areas
pqsigenvelope.Ed25519 and PQ accounts derive different addresses, account type cannot change, and PQ creation has only existed in development. Migration, backfill, audit, PQ manager accounts, and broader plugin generalization are outside this PR.