Skip to content

feat: add opt-in Falcon-1024 accounts - #40

Merged
PhearZero merged 19 commits into
masterfrom
feat/pq-accounts
Oct 1, 2026
Merged

PhearZero merged 19 commits into
masterfrom
feat/pq-accounts

Conversation

@bwmx

@bwmx bwmx commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

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_type is 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

  • add the Falcon-1024 Vault plugin and local/CI build setup
  • have the plugin return raw Falcon public keys while Intermezzo derives the canonical salt and Algorand address
  • accept account_type: "falcon1024" on user creation and return account_type in user responses
  • resolve and sign with either Ed25519 or Falcon-1024 user accounts
  • encode Algorand PQ transaction envelopes and apply their required fee before grouping
  • preserve caller Vault authorization while using the service AppRole for claims and PQ discovery
  • document setup, enablement order, and the no-migration constraint

Validation

  • yarn build
  • yarn lint
  • yarn format
  • yarn test --runInBand — 24 suites, 239 tests
  • GOCACHE=/private/tmp/intermezzo-go-cache go test -race ./...
  • yarn test:e2e — 2 suites, 46 tests against algod 5 and the Vault plugin

Deferred

  • Generalizing the Vault plugin beyond its current Falcon-1024 key generation/signing API. Intermezzo owns the Algorand address encoding in this PR.
  • PQ manager accounts. Manager accounts remain Ed25519 in this PR; adding Falcon-backed manager creation and signing is future work.

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

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.

bwmx added 2 commits September 1, 2026 16:14
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.
@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Coverage report

St.❔
Category Percentage Covered / Total
🟡 Statements
63.85% (+3.44% 🔼)
1491/2335
🔴 Branches
44.29% (+9.09% 🔼)
279/630
🔴 Functions
56.43% (+4.25% 🔼)
180/319
🟡 Lines
64.2% (+3.6% 🔼)
1388/2162

Test suite run success

239 tests passing in 24 suites.

Report generated by 🧪jest coverage report action from 8e40a19

bwmx added 2 commits September 7, 2026 11:05
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.
@bwmx bwmx changed the title feat: vault plugin for algorand post-quantum accounts feat: support pq falcon-1024 accounts Sep 7, 2026
bwmx added 3 commits September 8, 2026 15:59
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 added 4 commits September 9, 2026 15:10
…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.
`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.
Comment thread vault/plugin/derive.go Outdated
@bwmx bwmx changed the title feat: support pq falcon-1024 accounts feat: add opt-in Falcon-1024 accounts Sep 16, 2026
@bwmx
bwmx marked this pull request as ready for review September 16, 2026 15:21
@bwmx
bwmx requested a balanced review from Copilot September 16, 2026 15:46

This comment was marked as low quality.

@bwmx
bwmx marked this pull request as draft September 16, 2026 16:02
@bwmx
bwmx marked this pull request as ready for review September 17, 2026 11:52
@PhearZero
PhearZero self-requested a review September 21, 2026 18:55

@PhearZero PhearZero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
PhearZero added this pull request to stack #45 September 29, 2026 14:42
@PhearZero
PhearZero self-requested a review September 29, 2026 14:42

@PhearZero PhearZero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This LGTM as is, it is blocked by #44

@PhearZero
PhearZero merged commit 7d97e66 into master Oct 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants