protondrive: test against re-keyed files written by Proton's own clients

TestGoldenCapture (-capture DIR -capture-label NAME -remote NAME:PATH)
captures a folder another client wrote, re-keyed onto keys made for the
tests: blocks and signature-free SEIPD packets kept byte for byte, key
packets re-wrapped, signatures re-made with the same parameters (names,
hash keys and extended attributes encrypted again with their own session
keys, as their signatures are embedded), IDs and emails replaced.
TestGoldenFixtures reads every fixture in testdata/golden through the
backend from the fake server with every signature checked.

The fake server can now seed stored folders and keeps the times of
seeded nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-12 11:35:36 +01:00
1 parent 502a32b5f3
commit a2dc6f71f1
5 files changed
+1373 -5

No files matched your search

File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -876,9 +876,9 @@ func (u *User) ClaimSize(id string, size int64) {
}
// insertLink adds an active link l under parentID and returns its new
// ID, created now unless l has a creation time. It panics if parentID
// has an active or draft child with the same name hash, which a seeding
// test does not mean to create.
// ID, created and modified now unless l has those times. It panics if
// parentID has an active or draft child with the same name hash, which
// a seeding test does not mean to create.
func (u *User) insertLink(parentID string, l *link) string {
u.srv.mu.Lock()
defer u.srv.mu.Unlock()
@@ -895,7 +895,10 @@ func (u *User) insertLink(parentID string, l *link) string {
if l.createTime == 0 {
l.createTime = now
}
l.id, l.parentID, l.state, l.modifyTime = randomID(), parentID, linkStateActive, now
if l.modifyTime == 0 {
l.modifyTime = now
}
l.id, l.parentID, l.state = randomID(), parentID, linkStateActive
d.addLink(l)
return l.id
}
+38 -1
View File
@@ -26,6 +26,8 @@ type StoredLink struct {
MediaType string
ContentKeyPacket string // base64
ContentKeyPacketSignature string
CreateTime int64 // Unix seconds; the time of seeding if zero
ModifyTime int64 // Unix seconds; the time of seeding if zero
}
// StoredRevision is a revision of a file node.
@@ -33,6 +35,7 @@ type StoredRevision struct {
ManifestSignature string
XAttr string // armored; empty if none
SignatureEmail string
CreateTime int64 // Unix seconds; the time of seeding if zero
Blocks []StoredBlock // in index order, from 1
}
@@ -69,11 +72,14 @@ func (u *User) StoredFile(id string) StoredFile {
MediaType: l.mediaType,
ContentKeyPacket: l.contentKeyPacket,
ContentKeyPacketSignature: l.contentKeyPacketSignature,
CreateTime: l.createTime,
ModifyTime: l.modifyTime,
},
Revision: StoredRevision{
ManifestSignature: rev.manifestSignature,
XAttr: rev.xattr,
SignatureEmail: rev.signatureEmail,
CreateTime: rev.createTime,
},
}
for _, b := range rev.blocks {
@@ -93,7 +99,7 @@ func (u *User) SeedStoredFile(parentID string, f StoredFile) (id string) {
rev := &revision{
id: randomID(),
state: revisionStateActive,
createTime: time.Now().Unix(),
createTime: f.Revision.CreateTime,
manifestSignature: f.Revision.ManifestSignature,
xattr: f.Revision.XAttr,
signatureEmail: f.Revision.SignatureEmail,
@@ -107,6 +113,9 @@ func (u *User) SeedStoredFile(parentID string, f StoredFile) (id string) {
signatureEmail: b.SignatureEmail,
})
}
if rev.createTime == 0 {
rev.createTime = time.Now().Unix()
}
return u.insertLink(parentID, &link{
typ: linkTypeFile,
name: f.Link.Name,
@@ -118,10 +127,38 @@ func (u *User) SeedStoredFile(parentID string, f StoredFile) (id string) {
mediaType: f.Link.MediaType,
contentKeyPacket: f.Link.ContentKeyPacket,
contentKeyPacketSignature: f.Link.ContentKeyPacketSignature,
createTime: f.Link.CreateTime,
modifyTime: f.Link.ModifyTime,
revisions: []*revision{rev},
})
}
// StoredFolder is a folder node as the server stores it. Its Link has
// no file members.
type StoredFolder struct {
Link StoredLink
HashKey string // armored, encrypted to the node key
XAttr string // armored; empty if none
}
// SeedStoredFolder creates a folder in parentID from f, stored as given
// without any check, and returns its ID. It panics as insertLink does.
func (u *User) SeedStoredFolder(parentID string, f StoredFolder) (id string) {
return u.insertLink(parentID, &link{
typ: linkTypeFolder,
name: f.Link.Name,
nameHash: f.Link.NameHash,
nodeKey: f.Link.NodeKey,
nodePass: sealedPassphrase{armored: f.Link.NodePassphrase, signature: f.Link.NodePassphraseSignature},
signatureEmail: f.Link.SignatureEmail,
nameSignatureEmail: f.Link.NameSignatureEmail,
createTime: f.Link.CreateTime,
modifyTime: f.Link.ModifyTime,
hashKey: f.HashKey,
xattr: f.XAttr,
})
}
// FixedKeys is key material for AddUserWithKeys, in place of keys the
// fake would generate.
type FixedKeys struct {
+36
View File
@@ -5,6 +5,42 @@ it with a new one that links back. One entry per decision a future maintainer
would otherwise have to reverse-engineer.
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry.
## 2026-11-12 - Proton Drive golden fixtures are re-keyed captures
**Status:** accepted
**Context:** Plan Task 21 asks for fixtures of nodes written by Proton's
web app and official CLI. Captured as they are, they would carry the test
account's keys, IDs and emails. The owner chose to re-encrypt them under
keys made for the tests.
**Decision:** `TestGoldenCapture` (`-capture DIR -capture-label NAME
-remote NAME:PATH`) writes `backend/protondrive/testdata/golden/<label>/`,
read by `TestGoldenFixtures` from the fake server with every signature
checked. Preserved: every block byte for byte; every SEIPD packet which
holds no signature (re-wrapped session key only); the session key, cipher,
SEIPD version, AEAD mode and chunk size, compression algorithm and literal
data packet (format, file name, date, content) of names, hash keys and
extended attributes; signature version, type, hash, creation time,
lifetime and notations; key algorithm, version, creation time and AEAD
support; timestamps, sizes, flags, media types, block sizes and whether
they are recorded. Replaced: all keys (address, node, the captured
folder's parent) by new ones; every PKESK, re-wrapped in the same version;
every signature, re-made by the key replacing its signer (an embedded one
forces its SEIPD packet to be encrypted again with the same session key
and parameters, as a signature can't change in place); node passphrases,
new ones in a message of the same format; the captured folder's name hash;
intended recipients and signer user IDs; emails by
`golden@example.invalid`; IDs by placeholders. Dropped: block signatures
(the backend doesn't read them), block URLs and tokens. Each node lists
what was done to it in `Rekeyed`. Files with thumbnails are refused (the
fake can't serve them); the address key passphrase is sealed to a user key
the fake generates, so no user key is written.
**Alternatives:** Raw captures (account secrets in the repository);
fixtures re-made with gopenpgp from the decrypted content (loses the
other clients' packet formats, the point of the exercise).
**Consequences:** The fixtures test the formats of names, hash keys and
extended attributes as other clients lay them out, but their signature
bytes are ours. Packet headers are re-serialised with fixed lengths.
## 2026-11-12 - Proton Drive is a native port of Proton's Drive SDK
**Status:** accepted
+9
View File
@@ -10,6 +10,11 @@ Small, specific, independently actionable. Remove only when actually done.
done. Start from `docs/plans/001-protondrive-tier1-HANDOFF.md`.
Remaining: Task 21 (live verification, paid account never signed in
to), then the whole-branch review.
- [ ] protondrive: Task 21 golden fixtures - the capture
(`TestGoldenCapture`) and `TestGoldenFixtures` are in; the live capture
of the web app and CLI folders into `testdata/golden/` is the owner's to
run, inspect and commit (command in
`.superpowers/sdd/001-protondrive-tier1/task-21g-report.md`).
## Next
@@ -53,6 +58,10 @@ Small, specific, independently actionable. Remove only when actually done.
recorded (`bytes=0-N`, N at or past the end) is read as a ranged read,
so its manifest isn't checked; only a known size identifies a range
covering the whole file.
- [ ] protondrive: `TestGoldenCapture` refuses files with thumbnails (the
fake server serves none, and the manifest covers them) and keys other
than RSA, EdDSA, Ed25519 and Ed448; images and photos from Proton's apps
can't become golden fixtures until the fake serves thumbnails.
- [ ] protondrive: below the root, a node signed under another user's
email (a shared folder's members) is verified against public keys the
server supplies (`account.publicKeys`), so a hostile server can forge