What the code does
capsule-core/src/lifecycle/provenance.rs, Workspace::sign_lifecycle, builds every continuation manifest (delete, trash-restore, metadata-update) as:
let core = ManifestCore {
action,
prior_provenance_hash: prior,
retention_until,
metadata_blob_hash,
timestamp: now_rfc3339(),
client_version: self.client_version.clone(),
..base.clone() // <- created_by_user AND created_by_device come from the chain head
};
core.sign(self.device_signer.as_ref(), album.write_tier_signer()?)
So the record is signed by the current device but attributed to the base manifest's device.
Why that does not verify
capsule-core/src/crypto/verify_asset.rs resolves the signature this way:
- step 6 —
directory.core.user_id != core.created_by_user → Reject(UnknownDevice);
directory.device(&core.created_by_device) → Reject(UnknownDevice) when absent;
- step 8 — the device signature must verify under that entry's
dsk_public.
design/cryptography/provenance.md states the same rule in words: "device_sig — hybrid Ed25519 + ML-DSA-65 by the uploading device's DSK", and design/web-upload.md:91 has the adopting client set created_by_user/created_by_device to "the adopter (the cryptographic author)", not to the original uploader.
So the pair names the signer of this record. Inheriting it is correct only while the writing device is the creating device:
| Case |
created_by_device |
signer |
verify_asset |
| same device continues |
creating device |
same |
passes |
| second device of the same account continues |
creating device |
other device |
BadDeviceSig |
writer member (S-C51) continues the owner's chain |
owner's device |
member's device |
UnknownDevice (member's device is not in the owner's directory) / BadDeviceSig |
The multi-device case is not exotic — it is any user with a phone and a laptop.
Server-side consequence
POST /v1/albums/{album_id}/ops enforces invariant 7 (capsule-server/src/routes/ops.rs): created_by_device must be in the caller's published directory. A manifest built by today's sign_lifecycle from another device therefore also gets 400 error.upload.device_not_authorized — for its own, independent reason. The server refusal and the verify_asset refusal are the same underlying defect seen from two sides.
This is why the writer-member lifecycle path opened by S-C51 (#405, PR #458) has no client that can produce a manifest anything accepts. The server side is landed and documented; the client side is this.
What a fix has to decide
Which of the two readings is normative, and then make one place enforce it:
- The signer is the author (what
verify_asset, provenance.md and web-upload.md all say): sign_lifecycle should set created_by_user/created_by_device from self.account / self.device_signer on every continuation, exactly as the three create paths already do (lifecycle/import.rs:465, lifecycle/drops.rs:305, drop/mod.rs:500). Then the chain records who made each change, which is what design/cryptography/provenance.md's "Provenance of Library Modifications" describes ("Every modification ... produces a provenance record — timestamp, device, client version, action").
- The creator is the author and a separate pair names the writer: then
verify_asset must resolve the signature against the writer's directory, the manifest needs the extra fields, and it is a schema change under a new protocol_version.
(1) needs no wire change and is what every doc in the tree already says. It is almost certainly the answer, but it changes what a stored chain means, so it should be decided explicitly rather than by patch.
Tests that should exist either way
capsule-core: a second device of the same account continues a chain; assert verify_asset accepts.
capsule-core: a writer member continues another account's chain; assert whatever the decision makes correct.
capsule-server: tests/ops.rs currently constructs its member-write envelope by hand and cannot catch this; once core can produce a real continuation, that case should be driven from a real signed manifest.
Refs #405. Found while addressing the review of PR #458.
What the code does
capsule-core/src/lifecycle/provenance.rs,Workspace::sign_lifecycle, builds every continuation manifest (delete,trash-restore,metadata-update) as:So the record is signed by the current device but attributed to the base manifest's device.
Why that does not verify
capsule-core/src/crypto/verify_asset.rsresolves the signature this way:directory.core.user_id != core.created_by_user→Reject(UnknownDevice);directory.device(&core.created_by_device)→Reject(UnknownDevice)when absent;dsk_public.design/cryptography/provenance.mdstates the same rule in words: "device_sig— hybrid Ed25519 + ML-DSA-65 by the uploading device's DSK", anddesign/web-upload.md:91has the adopting client setcreated_by_user/created_by_deviceto "the adopter (the cryptographic author)", not to the original uploader.So the pair names the signer of this record. Inheriting it is correct only while the writing device is the creating device:
created_by_deviceverify_assetBadDeviceSigS-C51) continues the owner's chainUnknownDevice(member's device is not in the owner's directory) /BadDeviceSigThe multi-device case is not exotic — it is any user with a phone and a laptop.
Server-side consequence
POST /v1/albums/{album_id}/opsenforces invariant 7 (capsule-server/src/routes/ops.rs):created_by_devicemust be in the caller's published directory. A manifest built by today'ssign_lifecyclefrom another device therefore also gets400 error.upload.device_not_authorized— for its own, independent reason. The server refusal and theverify_assetrefusal are the same underlying defect seen from two sides.This is why the writer-member lifecycle path opened by
S-C51(#405, PR #458) has no client that can produce a manifest anything accepts. The server side is landed and documented; the client side is this.What a fix has to decide
Which of the two readings is normative, and then make one place enforce it:
verify_asset,provenance.mdandweb-upload.mdall say):sign_lifecycleshould setcreated_by_user/created_by_devicefromself.account/self.device_signeron every continuation, exactly as the three create paths already do (lifecycle/import.rs:465,lifecycle/drops.rs:305,drop/mod.rs:500). Then the chain records who made each change, which is whatdesign/cryptography/provenance.md's "Provenance of Library Modifications" describes ("Every modification ... produces a provenance record — timestamp, device, client version, action").verify_assetmust resolve the signature against the writer's directory, the manifest needs the extra fields, and it is a schema change under a newprotocol_version.(1) needs no wire change and is what every doc in the tree already says. It is almost certainly the answer, but it changes what a stored chain means, so it should be decided explicitly rather than by patch.
Tests that should exist either way
capsule-core: a second device of the same account continues a chain; assertverify_assetaccepts.capsule-core: a writer member continues another account's chain; assert whatever the decision makes correct.capsule-server:tests/ops.rscurrently constructs its member-write envelope by hand and cannot catch this; once core can produce a real continuation, that case should be driven from a real signed manifest.Refs #405. Found while addressing the review of PR #458.